Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@
// File explorer improvements
"explorer.sortOrder": "type",

"cSpell.words": ["firestore", "kriasoft", "syncguard", "vitepress"]
"cSpell.words": ["csprng", "firestore", "kriasoft", "syncguard", "vitepress"]
}
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@

TypeScript distributed lock library that prevents race conditions across services. Supports Redis, PostgreSQL, and Firestore backends with automatic cleanup, fencing tokens, and bulletproof concurrency control.

## Documentation

- **Docs site:** https://kriasoft.com/syncguard/
- **Backend guides:** [Redis](./redis/README.md) · [PostgreSQL](./postgres/README.md) · [Firestore](./firestore/README.md)

## Requirements

- **Node.js** ≥20.0.0 (targets AsyncDisposable/`await using`; older runtimes require try/finally plus a polyfill, but official support is 20+)

## Compatibility

| Runtime / Backend | Support |
| ------------------ | --------------------------------------------------------------------------- |
| Node.js | 20+ (native AsyncDisposable/`await using`) |
| Bun | 1.0+ (used for `bun test`) |
| Redis backend | Redis 6+ with `ioredis` ^5 peer dependency |
| PostgreSQL backend | PostgreSQL 12+ with `postgres` ^3 peer dependency |
| Firestore backend | `@google-cloud/firestore` ^8 peer dependency (emulator supported for tests) |

## Installation

SyncGuard is backend-agnostic. Install the base package plus any backends you need:
Expand All @@ -20,9 +35,9 @@ SyncGuard is backend-agnostic. Install the base package plus any backends you ne
npm install syncguard

# Choose one or more backends (optional peer dependencies):
npm install ioredis # for Redis backend
npm install postgres # for PostgreSQL backend
npm install @google-cloud/firestore # for Firestore backend
npm install syncguard ioredis # Redis backend
npm install syncguard postgres # PostgreSQL backend
npm install syncguard @google-cloud/firestore # Firestore backend
```

Only install the backend packages you actually use. If you attempt to use a backend without its package installed, you'll get a clear error message.
Expand Down Expand Up @@ -92,6 +107,8 @@ await lock(

### Manual Lock Control with Automatic Cleanup

Node.js 20+ supports `await using` natively; for older runtimes, drop to try/finally (see below).

Use `await using` for automatic cleanup on all code paths (Node.js ≥20):

```typescript
Expand Down Expand Up @@ -572,6 +589,13 @@ acquisition: {
}
```

## Development

- `bun test test/unit` — fast unit tests
- `bun test test/contracts test/e2e` — contracts + e2e suite
- `npm run build` — type-check and emit `dist/`
- `npm run redis` / `npm run firestore` — spin up local Redis or Firestore emulator for tests

## Contributing

We welcome contributions! Here's how you can help:
Expand Down
14 changes: 7 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,10 @@ export const FENCE_THRESHOLDS = {
*/
WARN: "090000000000000",
} as const;

/**
* Maximum value that can be formatted as a 15-digit fence token.
* This is the format limit (10^15 - 1), distinct from FENCE_THRESHOLDS.MAX
* which is the operational limit enforced by backends.
*/
export const FENCE_FORMAT_MAX = 999_999_999_999_999n;
51 changes: 41 additions & 10 deletions common/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: MIT

import { createHash, randomBytes } from "node:crypto";
import { FENCE_FORMAT_MAX } from "./constants.js";
import { LockError } from "./errors.js";
import type { HashId } from "./types.js";

Expand Down Expand Up @@ -35,12 +36,11 @@ export function generateLockId(): string {

/**
* Canonical 96-bit hash for user keys (NFC normalized, 24 hex chars).
* Collision probability: ~6.3e-12 at 10^9 distinct IDs.
*
* @remarks **Non-cryptographic hash for observability only.**
* This function uses a fast, non-cryptographic hash algorithm suitable for
* sanitization, telemetry, and UI display. Do NOT use for security-sensitive
* collision resistance or any cryptographic purposes.
* Uses a fast triple-hash algorithm suitable for sanitization, telemetry,
* and UI display. Effective 96-bit space provides low collision probability
* for typical workloads. Do NOT use for security-sensitive purposes.
*
* @param value - User-provided key string
* @returns 24-character hex hash identifier
Expand Down Expand Up @@ -72,20 +72,28 @@ export function hashKey(value: string): HashId {
* Internal helper - backends use this for consistent fence formatting.
* 15-digit format guarantees full safety within Lua's 53-bit precision limit
* (2^53-1 ≈ 9.007e15) while providing 10^15 capacity (~31.7 years at 1M locks/sec).
* @param value - Fence counter (bigint or number)
* @param value - Fence counter (bigint or integer number)
* @returns 15-digit string (e.g., "000000000000001")
* @throws {LockError} "InvalidArgument" if value is negative or exceeds 15-digit limit
* @throws {LockError} "InvalidArgument" if value is not a finite non-negative integer
*/
export function formatFence(value: bigint | number): string {
// Convert to bigint and enforce integer + range
const n = typeof value === "number" ? BigInt(Math.trunc(value)) : value;
// Validate numbers before BigInt conversion to avoid leaking RangeError
if (typeof value === "number") {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
throw new LockError(
"InvalidArgument",
"Fence must be a finite non-negative integer",
);
}
}

const n = typeof value === "bigint" ? value : BigInt(value);

if (n < 0n) {
throw new LockError("InvalidArgument", "Fence must be non-negative");
}

if (n > 999_999_999_999_999n) {
// 15 digits max (10^15 - 1)
if (n > FENCE_FORMAT_MAX) {
throw new LockError(
"InvalidArgument",
`Fence exceeds 15-digit limit: ${n}`,
Expand Down Expand Up @@ -144,6 +152,29 @@ export function makeStorageKey(
backendLimitBytes: number,
reserveBytes: number,
): string {
// Validate configuration (fail fast on misconfigured backends)
if (
!Number.isFinite(backendLimitBytes) ||
!Number.isInteger(backendLimitBytes) ||
backendLimitBytes <= 0
) {
throw new LockError(
"InvalidArgument",
"backendLimitBytes must be a positive integer",
);
}

if (
!Number.isFinite(reserveBytes) ||
!Number.isInteger(reserveBytes) ||
reserveBytes < 0
) {
throw new LockError(
"InvalidArgument",
"reserveBytes must be a non-negative integer",
);
}

// Validate key is not empty
if (!key) {
throw new LockError("InvalidArgument", "Key must not be empty");
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "syncguard",
"version": "2.5.2",
"version": "2.5.3",
"description": "Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.",
"keywords": [
"atomic",
Expand Down Expand Up @@ -91,14 +91,14 @@
},
"devDependencies": {
"@google-cloud/firestore": "^8.0.0",
"@types/bun": "1.3.3",
"firebase-tools": "^14.26.0",
"@types/bun": "1.3.4",
"firebase-tools": "^14.27.0",
"gh-pages": "^6.3.0",
"husky": "^9.1.7",
"ioredis": "^5.8.1",
"lint-staged": "^16.2.7",
"postgres": "^3.4.7",
"prettier": "^3.7.3",
"prettier": "^3.7.4",
"prettier-plugin-sql": "^0.19.2",
"typescript": "^5.9.3",
"vitepress": "^2.0.0-alpha.15"
Expand Down
54 changes: 49 additions & 5 deletions test/unit/common/crypto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,34 @@ describe("makeStorageKey", () => {
it("should throw when prefix exceeds backend limit", () => {
const longPrefix = "x".repeat(500);
expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow(
"Prefix exceeds backend limit",
/Prefix exceeds backend limit/,
);
});

it("should reject invalid backendLimitBytes", () => {
expect(() => makeStorageKey("p", "k", -1, 0)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 0, 0)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", NaN, 0)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", Infinity, 0)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 1000.5, 0)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", -1, 0)).toThrow(
"backendLimitBytes must be a positive integer",
);
});

it("should reject invalid reserveBytes", () => {
expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 1000, NaN)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 1000, Infinity)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 1000, 0.5)).toThrow(LockError);
expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow(
"reserveBytes must be a non-negative integer",
);
});

it("should allow zero reserveBytes", () => {
expect(() => makeStorageKey("p", "k", 1000, 0)).not.toThrow();
});
});

describe("hashKey", () => {
Expand Down Expand Up @@ -245,14 +270,33 @@ describe("formatFence", () => {
expect(formatFence(999_999_999_999_999n)).toBe("999999999999999");
});

it("should truncate floating point numbers", () => {
expect(formatFence(42.9)).toBe("000000000000042");
expect(formatFence(42.1)).toBe("000000000000042");
it("should accept values at operational threshold (FENCE_THRESHOLDS.MAX)", () => {
// Sanity check: operational threshold (9e14) < format limit (10^15-1)
expect(formatFence(900_000_000_000_000)).toBe("900000000000000");
expect(formatFence(900_000_000_000_000n)).toBe("900000000000000");
});

it("should reject non-integer numbers", () => {
expect(() => formatFence(42.9)).toThrow(LockError);
expect(() => formatFence(42.1)).toThrow(LockError);
expect(() => formatFence(0.1)).toThrow(LockError);
});

it("should reject negative fractional values", () => {
// Edge case: -0.1 must not be truncated to 0
expect(() => formatFence(-0.1)).toThrow(LockError);
expect(() => formatFence(-0.0001)).toThrow(LockError);
});

it("should reject non-finite values", () => {
expect(() => formatFence(NaN)).toThrow(LockError);
expect(() => formatFence(Infinity)).toThrow(LockError);
expect(() => formatFence(-Infinity)).toThrow(LockError);
});

it("should throw for negative values", () => {
expect(() => formatFence(-1)).toThrow(LockError);
expect(() => formatFence(-1)).toThrow("Fence must be non-negative");
expect(() => formatFence(-1)).toThrow("finite non-negative integer");
expect(() => formatFence(-1n)).toThrow("Fence must be non-negative");
});

Expand Down