diff --git a/README.md b/README.md index 5ee58d8..d62b22f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -430,19 +481,21 @@ 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 { - 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' } }); @@ -450,6 +503,9 @@ export default { }; ``` +> 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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c533084 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@biomejs/biome': true + esbuild: true diff --git a/src/index.test.ts b/src/index.test.ts index 6cc1a07..4d60ff8 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -4,9 +4,16 @@ import { generateSnowflakeId, getSnowflakeTimestamp, parseSnowflakeId, + type SnowflakeConfig, snowflakeToDate, } from "./index.js"; +/** edge モード用の設定を生成するファクトリ */ +const createEdgeConfig = (overrides: Partial = {}): SnowflakeConfig => ({ + mode: "edge", + ...overrides, +}); + describe("Snowflake ID Generator", () => { beforeEach(() => { vi.clearAllMocks(); @@ -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+$/); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index 5930d94..5a949a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,24 @@ +/** + * Snowflake ID generation mode + * + * - `"default"`: Ensures uniqueness via an assigned `(datacenterId, workerId)` and a sequence. + * - `"edge"`: Fills the 22 non-timestamp bits with cryptographic randomness (entropy). + * Useful for serverless/edge environments where a stable worker number cannot be assigned. + */ +export type SnowflakeMode = "default" | "edge"; + /** * Snowflake ID configuration */ export interface SnowflakeConfig { /** Custom epoch in milliseconds (defaults to 2020-01-01T00:00:00.000Z) */ epoch?: number; - /** Datacenter ID (0-31) */ + /** Datacenter ID (0-31). Ignored when `mode` is `"edge"`. */ datacenterId?: number; - /** Worker ID (0-31) */ + /** Worker ID (0-31). Ignored when `mode` is `"edge"`. */ workerId?: number; + /** Generation mode (defaults to `"default"`) */ + mode?: SnowflakeMode; } /** @@ -30,6 +41,12 @@ const MAX_DATACENTER_ID = (1 << DATACENTER_BITS) - 1; // 31 const MAX_WORKER_ID = (1 << WORKER_BITS) - 1; // 31 const MAX_SEQUENCE = (1 << SEQUENCE_BITS) - 1; // 4095 +/** + * Number of random bits used in edge mode (datacenter + worker + sequence = 22) + */ +const ENTROPY_BITS = DATACENTER_BITS + WORKER_BITS + SEQUENCE_BITS; // 22 +const MAX_ENTROPY = (1 << ENTROPY_BITS) - 1; // 4194303 + /** * Bit shifts for ID components */ @@ -38,11 +55,32 @@ const WORKER_SHIFT = SEQUENCE_BITS; const DATACENTER_SHIFT = SEQUENCE_BITS + WORKER_BITS; const TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_BITS + DATACENTER_BITS; +/** + * Generate 22 bits of cryptographic randomness for edge mode. + * Uses the global `crypto.getRandomValues` (Workers, browsers, Node 18+). + */ +const generateEntropy = (): number => { + const buffer = new Uint32Array(1); + crypto.getRandomValues(buffer); + // Extract the lower 22 bits (result is always a non-negative value 0..MAX_ENTROPY) + return buffer[0] & MAX_ENTROPY; +}; + /** * Create a Snowflake ID generator function */ export const createSnowflake = (config: SnowflakeConfig = {}) => { - const { epoch = DEFAULT_EPOCH, datacenterId = 0, workerId = 0 } = config; + const { epoch = DEFAULT_EPOCH, datacenterId = 0, workerId = 0, mode = "default" } = config; + + // Edge mode: fill the non-timestamp bits with cryptographic randomness. + // datacenterId / workerId are ignored (and not validated) in this mode. + if (mode === "edge") { + return (): string => { + const timestamp = Date.now(); + const id = (BigInt(timestamp - epoch) << BigInt(TIMESTAMP_SHIFT)) | BigInt(generateEntropy()); + return id.toString(); + }; + } // Validate configuration if (datacenterId < 0 || datacenterId > MAX_DATACENTER_ID) { @@ -102,12 +140,33 @@ export const generateSnowflakeId = (config: SnowflakeConfig = {}): string => { /** * Parse a Snowflake ID into its components + * + * In `"edge"` mode the non-timestamp bits are random, so `datacenterId`, `workerId`, + * and `sequence` are returned as `null` and the raw 22-bit value is returned as `entropy`. + * In `"default"` mode the worker fields are returned and `entropy` is `null`. */ -export const parseSnowflakeId = (id: string, epoch = DEFAULT_EPOCH) => { +export const parseSnowflakeId = ( + id: string, + epoch = DEFAULT_EPOCH, + mode: SnowflakeMode = "default" +) => { const idBigInt = BigInt(id); - // Extract components using bit operations const timestamp = Number(idBigInt >> BigInt(TIMESTAMP_SHIFT)) + epoch; + const date = new Date(timestamp); + + if (mode === "edge") { + return { + timestamp, + datacenterId: null, + workerId: null, + sequence: null, + entropy: Number(idBigInt & BigInt(MAX_ENTROPY)), + date, + }; + } + + // Extract components using bit operations const datacenterId = Number((idBigInt >> BigInt(DATACENTER_SHIFT)) & BigInt(MAX_DATACENTER_ID)); const workerId = Number((idBigInt >> BigInt(WORKER_SHIFT)) & BigInt(MAX_WORKER_ID)); const sequence = Number(idBigInt & BigInt(MAX_SEQUENCE)); @@ -117,7 +176,8 @@ export const parseSnowflakeId = (id: string, epoch = DEFAULT_EPOCH) => { datacenterId, workerId, sequence, - date: new Date(timestamp), + entropy: null, + date, }; };