Skip to content

API Reference โ€‹

Complete reference for SyncGuard's public API. For concepts and patterns, see the Guide.

For Library Authors & Contributors

This page documents the public API. For technical specifications and backend implementation requirements, see the specs directory on GitHub.

Primary API โ€‹

lock() โ€‹

Auto-managed distributed lock with retry logic and automatic release.

typescript
function lock<T>(
  fn: () => Promise<T> | T,
  config: LockConfig & { acquisition?: AcquisitionOptions },
): Promise<T>;

Usage:

typescript
import { lock } from "syncguard";

// Basic usage
await lock(
  async () => {
    // Critical section
  },
  { key: "resource:123" },
);

// With configuration
await lock(workFn, {
  key: "job:daily-report",
  ttlMs: 60000, // Lock expires after 60s
  timeoutMs: 10000, // Give up acquisition after 10s
  maxRetries: 20, // Retry up to 20 times
});

Behavior:

  • Acquires lock with automatic retry (exponential backoff with jitter)
  • Executes function after successful acquisition
  • Releases lock in finally block (even if function throws)
  • Throws LockError if acquisition times out or encounters system errors
  • Function errors propagate normally (not masked by release errors)

See Core Concepts for retry configuration details.


createLock() syncguard/redis syncguard/firestore โ€‹

Factory for backend-specific auto-managed lock functions.

typescript
import { createLock } from "syncguard/redis";
import Redis from "ioredis";

const redis = new Redis();
const lock = createLock(redis, {
  keyPrefix: "my-app", // Default: "syncguard"
});

await lock(workFn, { key: "resource:123" });
typescript
import { createLock } from "syncguard/firestore";
import { Firestore } from "@google-cloud/firestore";

const db = new Firestore();
const lock = createLock(db, {
  collection: "app_locks", // Default: "locks"
  fenceCollection: "app_fences", // Default: "fence_counters"
});

await lock(workFn, { key: "resource:123" });

When to use: Configure backend once, reuse lock function across your application.


Backend Interface โ€‹

For manual lock control with custom retry logic or long-running tasks.

createRedisBackend() syncguard/redis โ€‹

typescript
import { createRedisBackend } from "syncguard/redis";
import Redis from "ioredis";

const redis = new Redis();
const backend = createRedisBackend(redis, {
  keyPrefix: "syncguard",
  cleanupInIsLocked: false,
});

See Redis Backend for configuration details.


createFirestoreBackend() syncguard/firestore โ€‹

typescript
import { createFirestoreBackend } from "syncguard/firestore";
import { Firestore } from "@google-cloud/firestore";

const db = new Firestore();
const backend = createFirestoreBackend(db, {
  collection: "locks",
  fenceCollection: "fence_counters",
  cleanupInIsLocked: false,
});

Index Required

Firestore requires a single-field ascending index on the lockId field for optimal performance. See Firestore Backend for details.


backend.acquire() โ€‹

Request exclusive lock on a resource.

typescript
acquire(opts: {
  key: string;
  ttlMs: number;
  signal?: AbortSignal;
}): Promise<AcquireResult<C>>;

Usage:

typescript
const result = await backend.acquire({
  key: "payment:123",
  ttlMs: 30000, // Lock expires after 30s
});

if (result.ok) {
  const { lockId, expiresAtMs, fence } = result;
  // You now hold the lock
} else {
  // result.reason === "locked" (another process holds it)
}

Returns:

  • Success: { ok: true, lockId, expiresAtMs, fence }
  • Contention: { ok: false, reason: "locked" }
  • System errors throw LockError

Notes:

  • Single-attempt operation (no automatic retry)
  • fence is guaranteed present for Redis/Firestore backends (compile-time type safety)
  • lockId is required for all subsequent operations (release, extend, lookup)

See Fencing Tokens for fence token usage patterns.


backend.release() โ€‹

Release lock ownership.

typescript
release(opts: {
  lockId: string;
  signal?: AbortSignal;
}): Promise<ReleaseResult>;

Usage:

typescript
const released = await backend.release({ lockId });

if (released.ok) {
  // Lock successfully released
} else {
  // Lock was already expired/released
}

Returns:

  • Success: { ok: true }
  • Failure: { ok: false } (lock was expired, not found, or ownership mismatch)
  • System errors throw LockError

Notes:

  • Idempotent: safe to call even if lock is expired/released
  • Only the owner (matching lockId) can release
  • Failed release means lock was absent (expired or never existed)

backend.extend() โ€‹

Extend lock TTL before expiration.

typescript
extend(opts: {
  lockId: string;
  ttlMs: number;
  signal?: AbortSignal;
}): Promise<ExtendResult>;

Usage:

typescript
const extended = await backend.extend({
  lockId,
  ttlMs: 60000, // Reset to 60s from now
});

if (extended.ok) {
  console.log(`Lock now expires at ${new Date(extended.expiresAtMs)}`);
} else {
  console.log("Lost lock ownership");
}

Returns:

  • Success: { ok: true, expiresAtMs } (new expiration time)
  • Failure: { ok: false } (lock was expired or not found)
  • System errors throw LockError

Notes:

  • Replaces TTL entirely (doesn't add to remaining time)
  • Returns expiresAtMs for heartbeat scheduling
  • Cannot resurrect expired locks (use acquire() instead)
  • Idempotent: safe to call even if lock is expired

TTL Replacement

extend({ lockId, ttlMs: 60000 }) sets expiration to 60s from now, not from the original acquisition. See Core Concepts.


backend.isLocked() โ€‹

Check if resource is currently locked (simple boolean).

typescript
isLocked(opts: {
  key: string;
  signal?: AbortSignal;
}): Promise<boolean>;

Usage:

typescript
const locked = await backend.isLocked({ key: "resource:123" });

if (locked) {
  console.log("Resource is locked by someone");
} else {
  console.log("Resource is available");
}

Returns:

  • true if actively locked
  • false if not locked or expired

Notes:

  • Read-only operation (no side effects)
  • Does not reveal lock owner or expiration time
  • For detailed info, use backend.lookup() or getByKey() helper

backend.lookup() โ€‹

Lower-level diagnostic method for detailed lock information.

typescript
// By key (O(1) direct access)
lookup(opts: { key: string; signal?: AbortSignal }): Promise<LockInfo<C> | null>;

// By lockId (reverse lookup + verification)
lookup(opts: { lockId: string; signal?: AbortSignal }): Promise<LockInfo<C> | null>;

Prefer Helper Functions

For better discoverability and clearer intent, use the diagnostic helpers instead: getByKey(), getById(), and owns(). These provide a more ergonomic API while calling lookup() internally. See Diagnostics & Helpers for recommended usage patterns.

Usage:

typescript
// Check resource status
const info = await backend.lookup({ key: "resource:123" });
if (info) {
  console.log(`Expires at ${new Date(info.expiresAtMs)}`);
  console.log(`Fence: ${info.fence}`);
}

// Check ownership
const owned = await backend.lookup({ lockId });
if (owned) {
  console.log("Still own the lock");
}

Returns:

  • LockInfo<C> if lock exists and is not expired
  • null if not found or expired (no distinction)

Notes:

  • Returns sanitized data (hashed keys/lockIds)
  • For raw data access, use getByKeyRaw() or getByIdRaw() helpers
  • Read-only operation (no side effects)
  • Includes fence for fencing-capable backends
  • Atomicity: Redis uses atomic Lua scripts for lockId lookup (multi-key reads); Firestore uses non-atomic indexed queries with post-read verification (per ADR-011, both approaches ensure portability for diagnostic use)

Diagnostic Use Only

Lookup is for diagnostics, UI, and monitoring โ€” NOT a correctness guard. Never use lookup() โ†’ mutate patterns. Correctness relies on atomic ownership verification built into release() and extend() operations (ADR-003).


backend.capabilities โ€‹

Backend capability introspection.

typescript
interface BackendCapabilities {
  supportsFencing: boolean;
  timeAuthority: "server" | "client";
}

Usage:

typescript
const backend = createRedisBackend(redis);

console.log(backend.capabilities.supportsFencing); // true (Redis always provides fences)
console.log(backend.capabilities.timeAuthority); // "server" (Redis uses server time)

Redis: { supportsFencing: true, timeAuthority: "server" }Firestore: { supportsFencing: true, timeAuthority: "client" }


Result Types โ€‹

AcquireResult<C> โ€‹

Discriminated union for acquisition outcomes.

typescript
type AcquireResult<C extends BackendCapabilities> =
  | {
      ok: true;
      lockId: string;
      expiresAtMs: number;
      fence: Fence; // Required when C['supportsFencing'] === true
    }
  | {
      ok: false;
      reason: "locked";
    };

Pattern matching:

typescript
const result = await backend.acquire({ key: "resource:123", ttlMs: 30000 });

if (result.ok) {
  // TypeScript knows: lockId, expiresAtMs, fence are available
  const { lockId, fence } = result;
} else {
  // TypeScript knows: reason is "locked"
  console.log("Contention:", result.reason);
}

ReleaseResult โ€‹

Simple success/failure result.

typescript
type ReleaseResult = { ok: true } | { ok: false };

Interpretation:

  • { ok: true }: Lock successfully released
  • { ok: false }: Lock was absent (expired or never existed)

ExtendResult โ€‹

Extension result with new expiration time.

typescript
type ExtendResult = { ok: true; expiresAtMs: number } | { ok: false };

Usage:

typescript
const extended = await backend.extend({ lockId, ttlMs: 60000 });

if (extended.ok) {
  // Schedule next heartbeat based on new expiration
  const nextHeartbeat = extended.expiresAtMs - Date.now() - 5000;
}

LockInfo<C> โ€‹

Sanitized lock information (returned by lookup()).

typescript
type LockInfo<C extends BackendCapabilities> = {
  keyHash: HashId; // SHA-256 hash (24 hex chars)
  lockIdHash: HashId;
  expiresAtMs: number; // Unix timestamp
  acquiredAtMs: number;
  fence: Fence; // Required when C['supportsFencing'] === true
};

Security note: lookup() returns hashed identifiers to prevent accidental logging of sensitive keys/lockIds. For debugging, use getByKeyRaw() or getByIdRaw().


LockInfoDebug<C> โ€‹

Extended lock info with raw keys/lockIds (via getByKeyRaw()/getByIdRaw() helpers).

typescript
interface LockInfoDebug<C extends BackendCapabilities> extends LockInfo<C> {
  key: string; // Raw key (use in dev/debug only)
  lockId: string; // Raw lockId
}

Fence โ€‹

Fencing token type (15-digit zero-padded string per ADR-004).

typescript
type Fence = string; // e.g., "000000000000001"

Properties:

  • Lexicographic ordering matches chronological order
  • Direct string comparison works: fenceA > fenceB
  • JSON-safe (no BigInt precision issues)
  • Cross-backend consistent format (15 digits)
  • Guarantees full safety within Lua's 53-bit precision (2^53-1 โ‰ˆ 9.007e15)

Usage:

typescript
const fences = ["000000000000003", "000000000000001"];
const sorted = fences.sort(); // ["000000000000001", "000000000000003"] - lexicographic = chronological

if (newFence > storedFence) {
  // Accept write from newer lock holder
}

See Fencing Tokens for complete usage guide.


Configuration โ€‹

LockConfig โ€‹

Configuration for lock() function.

typescript
interface LockConfig {
  key: string; // Required: unique lock identifier
  ttlMs?: number; // Lock expiration (default: 30000ms)
  signal?: AbortSignal; // Cancel in-flight operations
  onReleaseError?: (
    error: Error,
    context: { lockId: string; key: string },
  ) => void;
  acquisition?: AcquisitionOptions; // Retry strategy
}

Example:

typescript
await lock(workFn, {
  key: "job:daily-report",
  ttlMs: 60000,
  timeoutMs: 10000,
  maxRetries: 20,
  onReleaseError: (err, ctx) => {
    console.error(`Failed to release ${ctx.key}:`, err);
  },
});

Fields:

FieldTypeDefaultDescription
keystring(required)Unique lock identifier (max 512 bytes)
ttlMsnumber30000Lock expiration in milliseconds
signalAbortSignalundefinedCancel lock operations
onReleaseErrorfunctionundefinedHandle release errors (optional)
acquisitionAcquisitionOptionsSee belowRetry strategy

AcquisitionOptions โ€‹

Retry strategy for lock acquisition.

typescript
interface AcquisitionOptions {
  maxRetries?: number; // Default: 10
  retryDelayMs?: number; // Base delay, default: 100ms
  backoff?: "exponential" | "fixed"; // Default: "exponential"
  jitter?: "equal" | "full" | "none"; // Default: "equal"
  timeoutMs?: number; // Hard limit, default: 5000ms
  signal?: AbortSignal; // Abort acquisition loop
}

Defaults:

typescript
{
  maxRetries: 10,
  retryDelayMs: 100,
  backoff: "exponential",
  jitter: "equal",
  timeoutMs: 5000
}

Example:

typescript
// More patient (high contention tolerance)
await lock(workFn, {
  key: "hot-resource",
  acquisition: {
    maxRetries: 20,
    timeoutMs: 10000,
  },
});

// Fail fast
await lock(workFn, {
  key: "quick-check",
  acquisition: {
    maxRetries: 3,
    timeoutMs: 1000,
  },
});

See Core Concepts for retry behavior details.


Backend Options โ€‹

RedisBackendOptions syncguard/redis โ€‹

typescript
interface RedisBackendOptions {
  keyPrefix?: string; // Default: "syncguard"
  cleanupInIsLocked?: boolean; // Default: false
}

Usage:

typescript
const backend = createRedisBackend(redis, {
  keyPrefix: "my-app", // Keys: "my-app:resource:123"
  cleanupInIsLocked: true, // Optional cleanup in isLocked()
});

See Redis Backend for details.


FirestoreBackendOptions syncguard/firestore โ€‹

typescript
interface FirestoreBackendOptions {
  collection?: string; // Default: "locks"
  fenceCollection?: string; // Default: "fence_counters"
  cleanupInIsLocked?: boolean; // Default: false
}

Usage:

typescript
const backend = createFirestoreBackend(db, {
  collection: "app_locks",
  fenceCollection: "app_fences",
  cleanupInIsLocked: true, // Optional cleanup in isLocked()
});

See Firestore Backend for details.


Diagnostics & Helpers โ€‹

Recommended diagnostic API โ€” These helper functions provide the most ergonomic way to inspect lock state. They offer better discoverability and clearer intent than calling backend.lookup() directly.

getByKey() โ€‹

Lookup lock by resource key (sanitized data). Primary method for checking resource lock status.

typescript
function getByKey<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  key: string,
  opts?: { signal?: AbortSignal },
): Promise<LockInfo<C> | null>;

Usage:

typescript
import { getByKey } from "syncguard";

const info = await getByKey(backend, "resource:123");
if (info) {
  console.log(`Lock expires in ${info.expiresAtMs - Date.now()}ms`);
  console.log(`Fence: ${info.fence}`);
} else {
  console.log("Resource is not locked");
}

Use this when: You need to check if a resource is locked and get detailed information about the lock holder.


getById() โ€‹

Lookup lock by lockId (sanitized data). Primary method for checking lock ownership.

typescript
function getById<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  lockId: string,
  opts?: { signal?: AbortSignal },
): Promise<LockInfo<C> | null>;

Usage:

typescript
import { getById } from "syncguard";

const info = await getById(backend, lockId);
if (info) {
  console.log("Own the lock, fence:", info.fence);
  console.log(`Expires in ${info.expiresAtMs - Date.now()}ms`);
} else {
  console.log("No longer own the lock");
}

Use this when: You need detailed information about a lock you're holding, including expiration time and fence tokens.


owns() โ€‹

Quick boolean ownership check. Simplified method for yes/no ownership questions.

typescript
function owns<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  lockId: string,
): Promise<boolean>;

Diagnostic Use Only

This is for diagnostics, UI, and monitoring โ€” NOT a correctness guard. Never use owns() โ†’ mutate patterns. Correctness relies on atomic ownership verification built into release() and extend() operations (ADR-003).

Usage:

typescript
import { owns } from "syncguard";

if (await owns(backend, lockId)) {
  console.log("Still own the lock");
} else {
  console.log("Lost ownership");
}

Use this when: You only need a boolean answer about ownership and don't need additional details.

Equivalent to: !!(await backend.lookup({ lockId }))


Lower-Level Access

These helpers call backend.lookup() internally. For advanced use cases requiring direct access to the backend method, you can use backend.lookup({ key }) or backend.lookup({ lockId }) directly.


getByKeyRaw() โ€‹

Lookup lock by key with raw identifiers (debugging).

typescript
function getByKeyRaw<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  key: string,
  opts?: { signal?: AbortSignal },
): Promise<LockInfoDebug<C> | null>;

Usage:

typescript
import { getByKeyRaw } from "syncguard";

const debug = await getByKeyRaw(backend, "resource:123");
if (debug) {
  console.log("Raw key:", debug.key); // Original key
  console.log("Raw lockId:", debug.lockId); // Original lockId
}

Security

Use only in development/debugging. Avoid logging raw keys/lockIds in production to prevent accidental exposure of sensitive identifiers.


getByIdRaw() โ€‹

Lookup lock by lockId with raw identifiers (debugging).

typescript
function getByIdRaw<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  lockId: string,
  opts?: { signal?: AbortSignal },
): Promise<LockInfoDebug<C> | null>;

Error Handling โ€‹

LockError โ€‹

Structured error for system failures.

typescript
class LockError extends Error {
  code:
    | "ServiceUnavailable"
    | "AuthFailed"
    | "InvalidArgument"
    | "RateLimited"
    | "NetworkTimeout"
    | "AcquisitionTimeout"
    | "Internal";
  context?: {
    key?: string;
    lockId?: string;
    cause?: unknown;
  };
}

Usage:

typescript
import { LockError } from "syncguard";

try {
  await lock(workFn, { key: "resource:123" });
} catch (error) {
  if (error instanceof LockError) {
    console.error(`[${error.code}] ${error.message}`);

    // Handle specific codes
    switch (error.code) {
      case "AcquisitionTimeout":
        // Couldn't acquire lock within timeoutMs
        break;
      case "ServiceUnavailable":
        // Backend unavailable, retry later
        break;
      case "NetworkTimeout":
        // Client-side timeout
        break;
    }
  }
}

Error codes:

CodeCauseAction
AcquisitionTimeoutExceeded timeoutMs after retriesReduce contention or increase timeout
ServiceUnavailableBackend unavailableRetry with backoff
NetworkTimeoutClient/network timeoutCheck network connectivity
InvalidArgumentMalformed key/lockIdValidate input parameters
AuthFailedAuthentication failureCheck credentials
RateLimitedBackend rate limitingImplement backoff
AbortedOperation cancelled via AbortSignalUser-initiated cancellation
InternalUnexpected backend errorCheck logs, report if persistent

Notes:

  • Lock contention is not an error (returns { ok: false, reason: "locked" })
  • AcquisitionTimeout only thrown by lock() helper (not backend.acquire())
  • System errors include context (key, lockId, cause) when available

Telemetry โ€‹

withTelemetry() โ€‹

Opt-in observability decorator for lock operations.

typescript
function withTelemetry<C extends BackendCapabilities>(
  backend: LockBackend<C>,
  options: TelemetryOptions,
): LockBackend<C>;

Usage:

typescript
import { withTelemetry, createRedisBackend } from "syncguard";

const backend = createRedisBackend(redis);
const observed = withTelemetry(backend, {
  onEvent: (event) => {
    console.log("Lock event:", event.type, event.result);
  },
  includeRaw: false, // Redact raw keys/lockIds (default)
});

await observed.acquire({ key: "resource:123", ttlMs: 30000 });
// โ†’ { type: "acquire", result: "ok", keyHash: "...", ... }

Options:

typescript
interface TelemetryOptions {
  onEvent: (event: LockEvent) => void;
  includeRaw?: boolean | ((event: LockEvent) => boolean);
}

Event structure:

typescript
type LockEvent = {
  type: "acquire" | "release" | "extend" | "isLocked" | "lookup";
  result: "ok" | "fail";
  keyHash?: HashId; // Always included
  lockIdHash?: HashId; // For operations using lockId
  reason?: "expired" | "not-found" | "locked"; // Best-effort detail
  key?: string; // Only if includeRaw allows
  lockId?: string; // Only if includeRaw allows
};

Notes:

  • Zero-cost when not applied (no overhead in core backends)
  • Events emitted asynchronously (never block operations)
  • Default redaction (includeRaw: false) prevents accidental exposure
  • reason field provides operational insights (expired vs not-found)

Privacy

Set includeRaw: true only in development/debugging. Raw keys/lockIds may contain sensitive data (user IDs, payment IDs, etc.).


Type Utilities โ€‹

hasFence() โ€‹

Type guard for fencing token presence.

typescript
function hasFence<C extends BackendCapabilities>(
  result: AcquireResult<C>,
): result is AcquireOk<C> & { fence: Fence };

Usage:

typescript
import { hasFence } from "syncguard";

// Generic function accepting unknown backend types
function processWithAnyBackend<C extends BackendCapabilities>(
  result: AcquireResult<C>,
) {
  if (hasFence(result)) {
    // Type guard for generic contexts
    console.log("Fence:", result.fence);
  }
}

Note: Most application code uses typed backends (Redis/Firestore) and doesn't need hasFence() since TypeScript knows fence exists at compile-time.


validateLockId() โ€‹

Client-side validation for lockId format.

typescript
function validateLockId(lockId: string): void;

Usage:

typescript
import { validateLockId } from "syncguard";

try {
  validateLockId(userProvidedLockId);
  await backend.release({ lockId: userProvidedLockId });
} catch (error) {
  // LockError("InvalidArgument", "Invalid lockId format...")
}

Valid format: Exactly 22 base64url characters (^[A-Za-z0-9_-]{22}$)


normalizeAndValidateKey() โ€‹

Key normalization and validation.

typescript
function normalizeAndValidateKey(key: string): string;

Usage:

typescript
import { normalizeAndValidateKey } from "syncguard";

const normalized = normalizeAndValidateKey(userKey);
await backend.acquire({ key: normalized, ttlMs: 30000 });

Behavior:

  • Unicode NFC normalization
  • UTF-8 byte length validation (max 512 bytes)
  • Throws LockError("InvalidArgument") if key exceeds limit

generateLockId() โ€‹

Generate cryptographically secure lockId.

typescript
function generateLockId(): string;

Usage:

typescript
import { generateLockId } from "syncguard";

const lockId = generateLockId();
// โ†’ "a1b2c3d4e5f6g7h8i9j0k1" (22 base64url chars, 128 bits entropy)

Note: Used internally by acquire(). Rarely needed in application code.


hashKey() โ€‹

SHA-256 hash for sanitized identifiers.

typescript
function hashKey(value: string): HashId;

Usage:

typescript
import { hashKey } from "syncguard";

const hash = hashKey("resource:123");
// โ†’ "a1b2c3d4e5f6g7h8i9j0k1l2" (24 hex chars, 96 bits)

Note: Used internally by lookup(). Useful for custom telemetry implementations.


Constants โ€‹

typescript
// Backend defaults (TTL only)
const BACKEND_DEFAULTS = {
  ttlMs: 30_000, // 30 seconds
} as const;

// Lock helper defaults (retry config)
const LOCK_DEFAULTS = {
  maxRetries: 10,
  retryDelayMs: 100,
  timeoutMs: 5_000,
  backoff: "exponential",
  jitter: "equal",
} as const;

// Key validation
const MAX_KEY_LENGTH_BYTES = 512;

Usage:

typescript
import { BACKEND_DEFAULTS, MAX_KEY_LENGTH_BYTES } from "syncguard";

console.log(BACKEND_DEFAULTS.ttlMs); // 30000
console.log(MAX_KEY_LENGTH_BYTES); // 512

Internal Constant

TIME_TOLERANCE_MS = 1000 is an internal constant used by all backends for consistent liveness checks. It's not exported or user-configurable (ADR-005). Both Redis and Firestore use the same 1000ms tolerance automatically.