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
96 changes: 76 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
## ✨ Features

- **Multi-platform Support**: Works in browsers, Node.js, Service Workers, and Edge Workers (Cloudflare Workers, etc.)
- **Edge Mode (Entropy)**: Optional `mode: 'edge'` generates IDs with cryptographic randomness — no worker-id assignment needed for serverless/edge runtimes
- **Zero Dependencies**: Lightweight with no external dependencies
- **Functional Style**: Pure functions without classes
- **Time-sortable**: IDs are chronologically sortable
Expand Down Expand Up @@ -61,6 +62,33 @@ const generateId = createSnowflake({
const id = generateId();
```

### Edge Mode (Entropy)

For serverless / edge runtimes (e.g. Cloudflare Workers) where you **cannot assign a stable
`workerId`** to each ephemeral isolate, use `mode: 'edge'`. Instead of an assigned
`(datacenterId, workerId)` + sequence, the 22 non-timestamp bits are filled with
**cryptographic randomness** (`crypto.getRandomValues`). This removes the need for any
worker-id assignment and ensures uniqueness **probabilistically** via "timestamp + 22-bit entropy".

```typescript
import { createSnowflake } from '@tknf/snowflake';

// No datacenterId / workerId required
const generateId = createSnowflake({ mode: 'edge', epoch: 1640995200000 });
const id = generateId(); // "1843927461029384756" (numeric string, chronologically sortable)
```

- `mode` defaults to `'default'` (the existing assigned worker-id behavior). `'edge'` is opt-in.
- In edge mode `datacenterId` / `workerId` are **ignored** (even if provided).
- The output is unchanged: a numeric string that fits a 63-bit positive integer (storable in a
SQLite / Turso `INTEGER` column) and remains chronologically sortable at millisecond granularity.

> **Uniqueness is probabilistic.** 22 bits = 4,194,304 possibilities per millisecond. When
> generating `k` IDs within the same millisecond the collision probability (birthday bound) is
> approximately `k² / (2·2²²)` (e.g. ~0.12% at 100 IDs/ms). Because of this, consumers **should add
> a DB `UNIQUE` constraint and retry-on-collision as a backstop** so that even a theoretical
> collision causes no actual harm.

### Node.js Environment Variables

```typescript
Expand Down Expand Up @@ -123,8 +151,9 @@ Creates a Snowflake ID generator function.
**Parameters:**
- `config` (optional): Configuration object
- `epoch` (number): Custom epoch timestamp in milliseconds (default: 2020-01-01)
- `datacenterId` (number): Datacenter ID (0-31, default: 0)
- `workerId` (number): Worker ID (0-31, default: 0)
- `datacenterId` (number): Datacenter ID (0-31, default: 0). Ignored when `mode` is `'edge'`.
- `workerId` (number): Worker ID (0-31, default: 0). Ignored when `mode` is `'edge'`.
- `mode` (`'default' | 'edge'`): Generation mode (default: `'default'`). See [Edge Mode (Entropy)](#edge-mode-entropy).

**Returns:** Function that generates Snowflake IDs as strings

Expand Down Expand Up @@ -154,33 +183,43 @@ const id = generateSnowflakeId({
});
```

#### `parseSnowflakeId(id, epoch?)`
#### `parseSnowflakeId(id, epoch?, mode?)`

Parses a Snowflake ID into its components.

**Parameters:**
- `id` (string): Snowflake ID to parse
- `epoch` (number, optional): Epoch used for generation (default: 2020-01-01)
- `mode` (`'default' | 'edge'`, optional): Mode the ID was generated with (default: `'default'`). Edge IDs are bit-indistinguishable from default IDs, so you must pass the mode you generated with.

**Returns:** Object containing:
**Returns (default mode):** Object containing:
- `timestamp` (number): Generation timestamp
- `datacenterId` (number): Datacenter ID
- `workerId` (number): Worker ID
- `sequence` (number): Sequence number
- `entropy` (null): Always `null` in default mode
- `date` (Date): Generation date

**Returns (edge mode):** In edge mode the non-timestamp bits are random, so the worker fields are
**meaningless**. They are returned as `null` and the raw 22-bit value is returned as `entropy`:
- `timestamp` (number): Generation timestamp
- `datacenterId` / `workerId` / `sequence` (null): Not meaningful in edge mode
- `entropy` (number): The 22-bit random value (0–4,194,303)
- `date` (Date): Generation date

```typescript
// default mode
const parsed = parseSnowflakeId("1234567890123456789");
console.log(parsed);
// {
// timestamp: 1640995200123,
// datacenterId: 1,
// workerId: 2,
// sequence: 0,
// date: 2022-01-01T00:00:00.123Z
// }
// { timestamp: 1640995200123, datacenterId: 1, workerId: 2, sequence: 0, entropy: null, date: ... }

// edge mode
const edgeParsed = parseSnowflakeId("1843927461029384756", 1640995200000, "edge");
// { timestamp: ..., datacenterId: null, workerId: null, sequence: null, entropy: 4194301, date: ... }
```

> `getSnowflakeTimestamp` and `snowflakeToDate` read only the timestamp bits, so they work
> identically for both default and edge IDs (no `mode` argument needed).

#### `getSnowflakeTimestamp(id, epoch?)`

Extracts timestamp from a Snowflake ID.
Expand Down Expand Up @@ -344,6 +383,18 @@ unused | timestamp | datacenter| worker | sequence
- **Worker ID (5 bits)**: Identifies the worker process (0-31)
- **Sequence (12 bits)**: Counter for same-millisecond generation (0-4095)

In **edge mode** (`mode: 'edge'`), the `datacenter | worker | sequence` block is replaced by 22 bits
of cryptographic randomness:

```
1 bit | 41 bits | 22 bits
unused | timestamp | random entropy (crypto)
0 | ms-epoch | getRandomValues
```

- **Timestamp (41 bits)**: Milliseconds since custom epoch (same position as default mode)
- **Entropy (22 bits)**: `crypto.getRandomValues` (0-4,194,303)

## 💡 Examples

### High-Frequency Generation
Expand Down Expand Up @@ -430,26 +481,31 @@ navigator.serviceWorker.controller.postMessage({
});
```

### Cloudflare Workers
### Cloudflare Workers (Edge Mode)

In Workers your code runs across many ephemeral isolates, so you cannot assign a stable `workerId`
(reading `env.WORKER_ID` gives every isolate the same value, which risks collisions). Use
`mode: 'edge'` so uniqueness comes from cryptographic entropy instead of an assigned worker number:

```typescript
// Cloudflare Worker example
import { createSnowflake } from '@tknf/snowflake';

const generator = createSnowflake({ mode: 'edge', epoch: 1640995200000 });

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const generator = createSnowflake({
datacenterId: 1,
workerId: parseInt(env.WORKER_ID) || 0
});

const id = generator();

return new Response(JSON.stringify({ id }), {
headers: { 'Content-Type': 'application/json' }
});
}
};
```

> Edge mode is probabilistic — pair it with a DB `UNIQUE` constraint and retry-on-collision
> (e.g. when using a single-writer Turso/SQLite). See [Edge Mode (Entropy)](#edge-mode-entropy).

### Browser Fingerprinting

```typescript
Expand Down
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
allowBuilds:
'@biomejs/biome': true
esbuild: true
119 changes: 119 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import {
generateSnowflakeId,
getSnowflakeTimestamp,
parseSnowflakeId,
type SnowflakeConfig,
snowflakeToDate,
} from "./index.js";

/** edge モード用の設定を生成するファクトリ */
const createEdgeConfig = (overrides: Partial<SnowflakeConfig> = {}): SnowflakeConfig => ({
mode: "edge",
...overrides,
});

describe("Snowflake ID Generator", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -309,4 +316,116 @@ describe("Snowflake ID Generator", () => {
}
});
});

describe("edge モード(エントロピー)", () => {
// 22bit エントロピーの最大値(datacenter + worker + sequence = 22bit)
const MAX_ENTROPY = 2 ** 22 - 1; // 4194303

it("worker-id を指定せずにジェネレータ関数を返す", () => {
const generate = createSnowflake(createEdgeConfig());
expect(typeof generate).toBe("function");
});

it("数値文字列の ID を生成する", () => {
const id = createSnowflake(createEdgeConfig())();
expect(typeof id).toBe("string");
expect(id).toMatch(/^\d+$/);
});

it("生成 ID は 63bit の正の整数になる", () => {
const id = createSnowflake(createEdgeConfig())();
const value = BigInt(id);
expect(value).toBeGreaterThan(0n);
expect(value).toBeLessThan(1n << 63n);
});

it("datacenterId / workerId を無視する(範囲外でも例外を投げない)", () => {
const generate = createSnowflake(createEdgeConfig({ datacenterId: 999, workerId: -10 }));
expect(generate()).toMatch(/^\d+$/);
});

it("同一ミリ秒内でもエントロピーにより ID がばらつく", () => {
const fixed = Date.now();
vi.spyOn(Date, "now").mockReturnValue(fixed);

const generate = createSnowflake(createEdgeConfig());
// タイムスタンプ固定なので、ID の差分はエントロピーのみに依存する
const ids = Array.from({ length: 1000 }, () => generate());

vi.restoreAllMocks();

// 22bit 乱数のため 1000 件中の重複は理論上ごく僅か(~0.12 件)。十分にばらつくことを確認
expect(new Set(ids).size).toBeGreaterThanOrEqual(990);
});

it("時刻が進む高頻度生成では全 ID がユニークかつ昇順になる", () => {
let current = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => current++);

const generate = createSnowflake(createEdgeConfig());
const ids: string[] = [];
for (let i = 0; i < 10000; i++) {
ids.push(generate());
}

vi.restoreAllMocks();

expect(new Set(ids).size).toBe(ids.length);
for (let i = 1; i < ids.length; i++) {
expect(BigInt(ids[i])).toBeGreaterThan(BigInt(ids[i - 1]));
}
});

it("カスタム epoch を反映する", () => {
const epoch = 1640995200000; // 2022-01-01
const before = Date.now();
const id = createSnowflake(createEdgeConfig({ epoch }))();
const after = Date.now();

const timestamp = getSnowflakeTimestamp(id, epoch);
expect(timestamp).toBeGreaterThanOrEqual(before);
expect(timestamp).toBeLessThanOrEqual(after);
});

it("getSnowflakeTimestamp / snowflakeToDate が edge ID でも正しく動く", () => {
const before = Date.now();
const id = createSnowflake(createEdgeConfig())();
const after = Date.now();

const timestamp = getSnowflakeTimestamp(id);
expect(timestamp).toBeGreaterThanOrEqual(before);
expect(timestamp).toBeLessThanOrEqual(after);

const date = snowflakeToDate(id);
expect(date).toBeInstanceOf(Date);
expect(date.getTime()).toBe(timestamp);
});

it("parseSnowflakeId は edge ID を entropy として返し worker 系を null にする", () => {
const epoch = 1640995200000;
const id = createSnowflake(createEdgeConfig({ epoch }))();

const parsed = parseSnowflakeId(id, epoch, "edge");
expect(parsed.datacenterId).toBeNull();
expect(parsed.workerId).toBeNull();
expect(parsed.sequence).toBeNull();
expect(parsed.entropy).toBeGreaterThanOrEqual(0);
expect(parsed.entropy).toBeLessThanOrEqual(MAX_ENTROPY);
expect(parsed.timestamp).toBeGreaterThan(epoch);
expect(parsed.date).toBeInstanceOf(Date);
});

it("parseSnowflakeId は default モードでは entropy を null にする", () => {
const id = generateSnowflakeId({ datacenterId: 1, workerId: 2 });
const parsed = parseSnowflakeId(id);
expect(parsed.entropy).toBeNull();
expect(parsed.datacenterId).toBe(1);
expect(parsed.workerId).toBe(2);
});

it("generateSnowflakeId で edge モードの単発 ID を生成できる", () => {
const id = generateSnowflakeId(createEdgeConfig());
expect(id).toMatch(/^\d+$/);
});
});
});
Loading
Loading