refactor progress
This commit is contained in:
28
packages/snowflake/package.json
Normal file
28
packages/snowflake/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@fluxer/snowflake",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/Snowflake.tsx",
|
||||
"./src/*": "./src/*",
|
||||
"./*": "./*"
|
||||
},
|
||||
"main": "./src/Snowflake.tsx",
|
||||
"types": "./src/Snowflake.tsx",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"itty-time": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
263
packages/snowflake/src/Snowflake.tsx
Normal file
263
packages/snowflake/src/Snowflake.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FLUXER_EPOCH as FLUXER_EPOCH_NUMBER} from '@fluxer/constants/src/Core';
|
||||
|
||||
export const FLUXER_EPOCH = BigInt(FLUXER_EPOCH_NUMBER);
|
||||
|
||||
export const WORKER_ID_BITS = 10n;
|
||||
export const SEQUENCE_BITS = 12n;
|
||||
|
||||
const WORKER_ID_SHIFT = SEQUENCE_BITS;
|
||||
|
||||
export const TIMESTAMP_SHIFT = WORKER_ID_BITS + SEQUENCE_BITS;
|
||||
|
||||
export const MAX_WORKER_ID = (1n << WORKER_ID_BITS) - 1n;
|
||||
export const MAX_SEQUENCE = (1n << SEQUENCE_BITS) - 1n;
|
||||
|
||||
const MAX_FUTURE_DRIFT_MS = 86400000;
|
||||
|
||||
export interface SnowflakeGeneratorOptions {
|
||||
workerId?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface CreateSnowflakeOptions {
|
||||
timestamp: number | bigint;
|
||||
workerId?: number;
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
export interface SnowflakeParts {
|
||||
timestamp: Date;
|
||||
workerId: number;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
interface SnowflakeBitParts {
|
||||
relativeTimestamp: bigint;
|
||||
workerId: bigint;
|
||||
sequence: bigint;
|
||||
}
|
||||
|
||||
interface ResolvedSnowflakeGeneratorOptions {
|
||||
workerId: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
function resolveSnowflakeGeneratorOptions(
|
||||
workerIdOrOptions: number | SnowflakeGeneratorOptions | undefined,
|
||||
): ResolvedSnowflakeGeneratorOptions {
|
||||
if (typeof workerIdOrOptions === 'number') {
|
||||
return {
|
||||
workerId: workerIdOrOptions,
|
||||
now: Date.now,
|
||||
};
|
||||
}
|
||||
|
||||
if (workerIdOrOptions == null) {
|
||||
return {
|
||||
workerId: 0,
|
||||
now: Date.now,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
workerId: workerIdOrOptions.workerId ?? 0,
|
||||
now: workerIdOrOptions.now ?? Date.now,
|
||||
};
|
||||
}
|
||||
|
||||
function assertValidWorkerId(workerId: number): bigint {
|
||||
if (!Number.isInteger(workerId)) {
|
||||
throw new Error(`Worker ID must be between 0 and ${MAX_WORKER_ID}`);
|
||||
}
|
||||
|
||||
const workerIdBigInt = BigInt(workerId);
|
||||
if (workerIdBigInt < 0n || workerIdBigInt > MAX_WORKER_ID) {
|
||||
throw new Error(`Worker ID must be between 0 and ${MAX_WORKER_ID}`);
|
||||
}
|
||||
|
||||
return workerIdBigInt;
|
||||
}
|
||||
|
||||
function assertValidSequence(sequence: number): bigint {
|
||||
if (!Number.isInteger(sequence)) {
|
||||
throw new Error(`Sequence must be between 0 and ${MAX_SEQUENCE}`);
|
||||
}
|
||||
|
||||
const sequenceBigInt = BigInt(sequence);
|
||||
if (sequenceBigInt < 0n || sequenceBigInt > MAX_SEQUENCE) {
|
||||
throw new Error(`Sequence must be between 0 and ${MAX_SEQUENCE}`);
|
||||
}
|
||||
|
||||
return sequenceBigInt;
|
||||
}
|
||||
|
||||
function toRelativeTimestamp(timestamp: number | bigint): bigint {
|
||||
const timestampBigInt = BigInt(timestamp);
|
||||
const relativeTimestamp = timestampBigInt - FLUXER_EPOCH;
|
||||
|
||||
if (relativeTimestamp < 0n) {
|
||||
throw new Error('Timestamp must be on or after the Fluxer epoch');
|
||||
}
|
||||
|
||||
return relativeTimestamp;
|
||||
}
|
||||
|
||||
function toEpochTimestamp(relativeTimestamp: bigint): bigint {
|
||||
return relativeTimestamp + FLUXER_EPOCH;
|
||||
}
|
||||
|
||||
function toSnowflakeBitParts(snowflake: bigint): SnowflakeBitParts {
|
||||
return {
|
||||
relativeTimestamp: snowflake >> TIMESTAMP_SHIFT,
|
||||
workerId: (snowflake >> WORKER_ID_SHIFT) & MAX_WORKER_ID,
|
||||
sequence: snowflake & MAX_SEQUENCE,
|
||||
};
|
||||
}
|
||||
|
||||
function createSnowflakeBigInt(relativeTimestamp: bigint, workerId: bigint, sequence: bigint): bigint {
|
||||
return (relativeTimestamp << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;
|
||||
}
|
||||
|
||||
function getTimestampFromNow(now: () => number): bigint {
|
||||
return BigInt(now()) - FLUXER_EPOCH;
|
||||
}
|
||||
|
||||
export class SnowflakeGenerator {
|
||||
private readonly workerId: bigint;
|
||||
private readonly now: () => number;
|
||||
private sequence: bigint = 0n;
|
||||
private lastTimestamp: bigint = -1n;
|
||||
|
||||
constructor(workerIdOrOptions: number | SnowflakeGeneratorOptions = 0) {
|
||||
const options = resolveSnowflakeGeneratorOptions(workerIdOrOptions);
|
||||
this.workerId = assertValidWorkerId(options.workerId);
|
||||
this.now = options.now;
|
||||
}
|
||||
|
||||
generate(): bigint {
|
||||
let timestamp = getTimestampFromNow(this.now);
|
||||
|
||||
if (timestamp < this.lastTimestamp) {
|
||||
timestamp = this.lastTimestamp;
|
||||
}
|
||||
|
||||
if (timestamp === this.lastTimestamp) {
|
||||
this.sequence = (this.sequence + 1n) & MAX_SEQUENCE;
|
||||
if (this.sequence === 0n) {
|
||||
timestamp = this.waitUntilNextTimestamp();
|
||||
}
|
||||
} else {
|
||||
this.sequence = 0n;
|
||||
}
|
||||
|
||||
this.lastTimestamp = timestamp;
|
||||
return createSnowflakeBigInt(timestamp, this.workerId, this.sequence);
|
||||
}
|
||||
|
||||
getWorkerId(): number {
|
||||
return Number(this.workerId);
|
||||
}
|
||||
|
||||
private waitUntilNextTimestamp(): bigint {
|
||||
let timestamp = getTimestampFromNow(this.now);
|
||||
while (timestamp <= this.lastTimestamp) {
|
||||
timestamp = getTimestampFromNow(this.now);
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultGenerator: SnowflakeGenerator | null = null;
|
||||
|
||||
export function createSnowflakeGenerator(options: SnowflakeGeneratorOptions = {}): SnowflakeGenerator {
|
||||
return new SnowflakeGenerator(options);
|
||||
}
|
||||
|
||||
export function setDefaultSnowflakeGenerator(options: SnowflakeGeneratorOptions = {}): void {
|
||||
defaultGenerator = createSnowflakeGenerator(options);
|
||||
}
|
||||
|
||||
export function resetDefaultSnowflakeGenerator(): void {
|
||||
defaultGenerator = null;
|
||||
}
|
||||
|
||||
export function generateSnowflake(workerIdOrOptions?: number | SnowflakeGeneratorOptions): bigint {
|
||||
if (workerIdOrOptions !== undefined) {
|
||||
return new SnowflakeGenerator(workerIdOrOptions).generate();
|
||||
}
|
||||
|
||||
if (!defaultGenerator) {
|
||||
defaultGenerator = createSnowflakeGenerator();
|
||||
}
|
||||
|
||||
return defaultGenerator.generate();
|
||||
}
|
||||
|
||||
export function createSnowflake(options: CreateSnowflakeOptions): bigint {
|
||||
const workerId = assertValidWorkerId(options.workerId ?? 0);
|
||||
const sequence = assertValidSequence(options.sequence ?? 0);
|
||||
const relativeTimestamp = toRelativeTimestamp(options.timestamp);
|
||||
return createSnowflakeBigInt(relativeTimestamp, workerId, sequence);
|
||||
}
|
||||
|
||||
export function createSnowflakeFromTimestamp(timestamp: number | bigint, workerId = 0): bigint {
|
||||
return createSnowflake({timestamp, workerId});
|
||||
}
|
||||
|
||||
export function snowflakeToDate(snowflake: bigint): Date {
|
||||
const bitParts = toSnowflakeBitParts(snowflake);
|
||||
return new Date(Number(toEpochTimestamp(bitParts.relativeTimestamp)));
|
||||
}
|
||||
|
||||
export function parseSnowflake(snowflake: bigint): SnowflakeParts {
|
||||
const bitParts = toSnowflakeBitParts(snowflake);
|
||||
|
||||
return {
|
||||
timestamp: new Date(Number(toEpochTimestamp(bitParts.relativeTimestamp))),
|
||||
workerId: Number(bitParts.workerId),
|
||||
sequence: Number(bitParts.sequence),
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidSnowflake(value: unknown): value is bigint {
|
||||
if (typeof value !== 'bigint') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value < 0n) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bitParts = toSnowflakeBitParts(value);
|
||||
const timestamp = toEpochTimestamp(bitParts.relativeTimestamp);
|
||||
const timestampNumber = Number(timestamp);
|
||||
|
||||
if (timestampNumber < Number(FLUXER_EPOCH)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (timestampNumber > Date.now() + MAX_FUTURE_DRIFT_MS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
71
packages/snowflake/src/SnowflakeBuckets.tsx
Normal file
71
packages/snowflake/src/SnowflakeBuckets.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FLUXER_EPOCH, TIMESTAMP_SHIFT} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {ms} from 'itty-time';
|
||||
|
||||
export const SNOWFLAKE_BUCKET_SIZE_MS = BigInt(ms('10 days'));
|
||||
|
||||
function getRelativeTimestampForBucket(snowflake: bigint | null): bigint {
|
||||
if (snowflake == null) {
|
||||
return BigInt(Date.now()) - FLUXER_EPOCH;
|
||||
}
|
||||
|
||||
return snowflake >> TIMESTAMP_SHIFT;
|
||||
}
|
||||
|
||||
function createBucketRange(startBucket: number, endBucket: number): Array<number> {
|
||||
if (endBucket < startBucket) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const size = endBucket - startBucket + 1;
|
||||
const range = new Array<number>(size);
|
||||
|
||||
for (let index = 0; index < size; index += 1) {
|
||||
range[index] = startBucket + index;
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
export function makeBucket(snowflake: bigint | null): number {
|
||||
const timestamp = getRelativeTimestampForBucket(snowflake);
|
||||
return Math.floor(Number(timestamp / SNOWFLAKE_BUCKET_SIZE_MS));
|
||||
}
|
||||
|
||||
export function makeBucketString(snowflake: string | null): number {
|
||||
if (snowflake == null) {
|
||||
return makeBucket(null);
|
||||
}
|
||||
|
||||
return makeBucket(BigInt(snowflake));
|
||||
}
|
||||
|
||||
export function makeBuckets(startId: bigint | null, endId: bigint | null = null): Array<number> {
|
||||
const startBucket = makeBucket(startId);
|
||||
const endBucket = makeBucket(endId);
|
||||
return createBucketRange(startBucket, endBucket);
|
||||
}
|
||||
|
||||
export function makeBucketsString(startId: string | null, endId: string | null = null): Array<number> {
|
||||
const startBigInt = startId != null ? BigInt(startId) : null;
|
||||
const endBigInt = endId != null ? BigInt(endId) : null;
|
||||
return makeBuckets(startBigInt, endBigInt);
|
||||
}
|
||||
277
packages/snowflake/src/SnowflakeUtils.tsx
Normal file
277
packages/snowflake/src/SnowflakeUtils.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSnowflake,
|
||||
FLUXER_EPOCH,
|
||||
MAX_SEQUENCE,
|
||||
MAX_WORKER_ID,
|
||||
TIMESTAMP_SHIFT,
|
||||
} from '@fluxer/snowflake/src/Snowflake';
|
||||
|
||||
const FLUXER_EPOCH_NUMBER = Number(FLUXER_EPOCH);
|
||||
|
||||
function extractTimestampWithEpoch(snowflake: bigint, epoch: bigint): number {
|
||||
return Number((snowflake >> TIMESTAMP_SHIFT) + epoch);
|
||||
}
|
||||
|
||||
function toClampedTimestamp(timestamp: number): number {
|
||||
if (timestamp <= FLUXER_EPOCH_NUMBER) {
|
||||
return FLUXER_EPOCH_NUMBER;
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
function assertValidWorkerId(workerId: number): void {
|
||||
if (!Number.isInteger(workerId)) {
|
||||
throw new Error(`Worker ID must be between 0 and ${MAX_WORKER_ID}`);
|
||||
}
|
||||
|
||||
const workerIdBigInt = BigInt(workerId);
|
||||
if (workerIdBigInt < 0n || workerIdBigInt > MAX_WORKER_ID) {
|
||||
throw new Error(`Worker ID must be between 0 and ${MAX_WORKER_ID}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidSequenceValue(sequence: number): void {
|
||||
if (!Number.isInteger(sequence)) {
|
||||
throw new Error(`Snowflake sequence number overflow: ${sequence}`);
|
||||
}
|
||||
|
||||
if (sequence < 0 || sequence > Number(MAX_SEQUENCE)) {
|
||||
throw new Error(`Snowflake sequence number overflow: ${sequence}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTimestamp(snowflake: string): number {
|
||||
try {
|
||||
return extractTimestampWithEpoch(BigInt(snowflake), FLUXER_EPOCH);
|
||||
} catch (_error) {
|
||||
return Number.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTimestampBigInt(snowflake: bigint): number {
|
||||
return extractTimestampWithEpoch(snowflake, FLUXER_EPOCH);
|
||||
}
|
||||
|
||||
export function fromTimestamp(timestamp: number): string {
|
||||
const clampedTimestamp = toClampedTimestamp(timestamp);
|
||||
if (clampedTimestamp === FLUXER_EPOCH_NUMBER) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return createSnowflake({timestamp: clampedTimestamp}).toString();
|
||||
}
|
||||
|
||||
export function fromTimestampBigInt(timestamp: number): bigint {
|
||||
const clampedTimestamp = toClampedTimestamp(timestamp);
|
||||
if (clampedTimestamp === FLUXER_EPOCH_NUMBER) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
return createSnowflake({timestamp: clampedTimestamp});
|
||||
}
|
||||
|
||||
export function fromTimestampWithSequence(timestamp: number, sequence: SnowflakeSequence): string {
|
||||
const clampedTimestamp = toClampedTimestamp(timestamp);
|
||||
return createSnowflake({
|
||||
timestamp: clampedTimestamp,
|
||||
sequence: sequence.next(),
|
||||
}).toString();
|
||||
}
|
||||
|
||||
export function fromTimestampWithSequenceBigInt(timestamp: number, sequence: SnowflakeSequence, workerId = 0): bigint {
|
||||
assertValidWorkerId(workerId);
|
||||
|
||||
const clampedTimestamp = toClampedTimestamp(timestamp);
|
||||
return createSnowflake({
|
||||
timestamp: clampedTimestamp,
|
||||
workerId,
|
||||
sequence: sequence.next(),
|
||||
});
|
||||
}
|
||||
|
||||
export function atPreviousMillisecond(snowflake: string): string {
|
||||
return fromTimestamp(extractTimestamp(snowflake) - 1);
|
||||
}
|
||||
|
||||
export function atPreviousMillisecondBigInt(snowflake: bigint): bigint {
|
||||
return fromTimestampBigInt(extractTimestampBigInt(snowflake) - 1);
|
||||
}
|
||||
|
||||
export function atNextMillisecond(snowflake: string): string {
|
||||
return fromTimestamp(extractTimestamp(snowflake) + 1);
|
||||
}
|
||||
|
||||
export function atNextMillisecondBigInt(snowflake: bigint): bigint {
|
||||
return fromTimestampBigInt(extractTimestampBigInt(snowflake) + 1);
|
||||
}
|
||||
|
||||
export function compare(snowflake1: string | null, snowflake2: string | null): number {
|
||||
if (snowflake1 === snowflake2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (snowflake2 == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (snowflake1 == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (snowflake1.length > snowflake2.length) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (snowflake1.length < snowflake2.length) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return snowflake1 > snowflake2 ? 1 : -1;
|
||||
}
|
||||
|
||||
export function compareBigInt(snowflake1: bigint | null, snowflake2: bigint | null): number {
|
||||
if (snowflake1 === snowflake2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (snowflake2 == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (snowflake1 == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (snowflake1 > snowflake2) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (snowflake1 < snowflake2) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isProbablyAValidSnowflake(value: string | null | undefined): boolean {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const num = BigInt(value);
|
||||
return num > 0n;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sortBySnowflakeDesc<T extends {id: string}>(items: ReadonlyArray<T>): Array<T> {
|
||||
return [...items].sort((a, b) => compare(b.id, a.id));
|
||||
}
|
||||
|
||||
export function sortBySnowflakeDescBigInt<T extends {id: bigint}>(items: ReadonlyArray<T>): Array<T> {
|
||||
return [...items].sort((a, b) => compareBigInt(b.id, a.id));
|
||||
}
|
||||
|
||||
export function age(snowflake: string): number {
|
||||
const timestamp = extractTimestamp(snowflake);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Date.now() - timestamp;
|
||||
}
|
||||
|
||||
export function ageBigInt(snowflake: bigint): number {
|
||||
const timestamp = extractTimestampBigInt(snowflake);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Date.now() - timestamp;
|
||||
}
|
||||
|
||||
export function extractTimestampFromSnowflake(snowflake: string, epoch?: string | bigint): number {
|
||||
try {
|
||||
const epochBigInt = epoch != null ? (typeof epoch === 'string' ? BigInt(epoch) : epoch) : FLUXER_EPOCH;
|
||||
return extractTimestampWithEpoch(BigInt(snowflake), epochBigInt);
|
||||
} catch (_error) {
|
||||
return Number.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractTimestampFromSnowflakeAsDate(snowflake: string, epoch?: string | bigint): Date {
|
||||
const timestamp = extractTimestampFromSnowflake(snowflake, epoch);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export function extractTimestampFromSnowflakeAsDateBigInt(snowflake: bigint): Date {
|
||||
const timestamp = extractTimestampBigInt(snowflake);
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
return new Date(timestamp);
|
||||
}
|
||||
|
||||
export interface SnowflakeSequenceOptions {
|
||||
initialValue?: number;
|
||||
}
|
||||
|
||||
export class SnowflakeSequence {
|
||||
private seq: number;
|
||||
|
||||
constructor(options: SnowflakeSequenceOptions = {}) {
|
||||
const initialValue = options.initialValue ?? 0;
|
||||
assertValidSequenceValue(initialValue);
|
||||
this.seq = initialValue;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
if (this.seq > Number(MAX_SEQUENCE)) {
|
||||
throw new Error(`Snowflake sequence number overflow: ${this.seq}`);
|
||||
}
|
||||
|
||||
const current = this.seq;
|
||||
this.seq += 1;
|
||||
return current;
|
||||
}
|
||||
|
||||
willOverflowNext(): boolean {
|
||||
return this.seq > Number(MAX_SEQUENCE);
|
||||
}
|
||||
|
||||
peek(): number {
|
||||
return this.seq;
|
||||
}
|
||||
|
||||
reset(nextValue = 0): void {
|
||||
assertValidSequenceValue(nextValue);
|
||||
this.seq = nextValue;
|
||||
}
|
||||
}
|
||||
593
packages/snowflake/src/__tests__/Snowflake.test.tsx
Normal file
593
packages/snowflake/src/__tests__/Snowflake.test.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FLUXER_EPOCH as FLUXER_EPOCH_NUMBER} from '@fluxer/constants/src/Core';
|
||||
import {
|
||||
createSnowflake,
|
||||
createSnowflakeFromTimestamp,
|
||||
createSnowflakeGenerator,
|
||||
FLUXER_EPOCH,
|
||||
generateSnowflake,
|
||||
isValidSnowflake,
|
||||
MAX_WORKER_ID,
|
||||
parseSnowflake,
|
||||
resetDefaultSnowflakeGenerator,
|
||||
SnowflakeGenerator,
|
||||
setDefaultSnowflakeGenerator,
|
||||
snowflakeToDate,
|
||||
} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const WORKER_ID_BITS = 10n;
|
||||
const SEQUENCE_BITS = 12n;
|
||||
const TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
|
||||
const WORKER_ID_SHIFT = SEQUENCE_BITS;
|
||||
const MAX_SEQUENCE = (1n << SEQUENCE_BITS) - 1n;
|
||||
|
||||
describe('FLUXER_EPOCH', () => {
|
||||
it('should match the epoch constant from @fluxer/constants', () => {
|
||||
expect(FLUXER_EPOCH).toBe(BigInt(FLUXER_EPOCH_NUMBER));
|
||||
});
|
||||
|
||||
it('should equal January 1, 2015 00:00:00 UTC', () => {
|
||||
const expectedDate = new Date('2015-01-01T00:00:00.000Z');
|
||||
expect(Number(FLUXER_EPOCH)).toBe(expectedDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('MAX_WORKER_ID', () => {
|
||||
it('should be 1023 (2^10 - 1)', () => {
|
||||
expect(MAX_WORKER_ID).toBe(1023n);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SnowflakeGenerator', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create a generator with default worker ID of 0', () => {
|
||||
const generator = new SnowflakeGenerator();
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(0);
|
||||
});
|
||||
|
||||
it('should create a generator with specified worker ID', () => {
|
||||
const generator = new SnowflakeGenerator(42);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(42);
|
||||
});
|
||||
|
||||
it('should accept maximum worker ID (1023)', () => {
|
||||
const generator = new SnowflakeGenerator(1023);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(1023);
|
||||
});
|
||||
|
||||
it('should throw error for negative worker ID', () => {
|
||||
expect(() => new SnowflakeGenerator(-1)).toThrow('Worker ID must be between 0 and 1023');
|
||||
});
|
||||
|
||||
it('should throw error for worker ID exceeding maximum', () => {
|
||||
expect(() => new SnowflakeGenerator(1024)).toThrow('Worker ID must be between 0 and 1023');
|
||||
});
|
||||
|
||||
it('should throw error for very large worker ID', () => {
|
||||
expect(() => new SnowflakeGenerator(999999)).toThrow('Worker ID must be between 0 and 1023');
|
||||
});
|
||||
|
||||
it('should accept options object constructor', () => {
|
||||
const generator = new SnowflakeGenerator({workerId: 64});
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate unique snowflakes', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflakes = new Set<bigint>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
snowflakes.add(generator.generate());
|
||||
}
|
||||
expect(snowflakes.size).toBe(1000);
|
||||
});
|
||||
|
||||
it('should generate snowflakes with increasing values', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
let previous = generator.generate();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const current = generator.generate();
|
||||
expect(current).toBeGreaterThan(previous);
|
||||
previous = current;
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate snowflakes with correct structure', () => {
|
||||
const generator = new SnowflakeGenerator(5);
|
||||
const snowflake = generator.generate();
|
||||
expect(typeof snowflake).toBe('bigint');
|
||||
expect(snowflake).toBeGreaterThan(0n);
|
||||
});
|
||||
|
||||
it('should embed worker ID in generated snowflakes', () => {
|
||||
const workerId = 512;
|
||||
const generator = new SnowflakeGenerator(workerId);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(workerId);
|
||||
}
|
||||
});
|
||||
|
||||
it('should increment sequence within same millisecond', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflakes: Array<bigint> = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
snowflakes.push(generator.generate());
|
||||
}
|
||||
const firstTimestamp = parseSnowflake(snowflakes[0]).timestamp.getTime();
|
||||
const sameMillisecond = snowflakes.filter((s) => parseSnowflake(s).timestamp.getTime() === firstTimestamp);
|
||||
if (sameMillisecond.length > 1) {
|
||||
const sameMillisecondSequences = sameMillisecond.map((s) => parseSnowflake(s).sequence);
|
||||
for (let i = 1; i < sameMillisecondSequences.length; i++) {
|
||||
expect(sameMillisecondSequences[i]).toBeGreaterThan(sameMillisecondSequences[i - 1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should reset sequence when timestamp changes', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflake1 = generator.generate();
|
||||
const parsed1 = parseSnowflake(snowflake1);
|
||||
let snowflake2 = generator.generate();
|
||||
let parsed2 = parseSnowflake(snowflake2);
|
||||
let attempts = 0;
|
||||
while (parsed1.timestamp.getTime() === parsed2.timestamp.getTime() && attempts < 10000) {
|
||||
snowflake2 = generator.generate();
|
||||
parsed2 = parseSnowflake(snowflake2);
|
||||
attempts++;
|
||||
}
|
||||
if (parsed1.timestamp.getTime() !== parsed2.timestamp.getTime()) {
|
||||
expect(parsed2.sequence).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence overflow handling', () => {
|
||||
it('should handle rapid generation without duplicates', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflakes = new Set<bigint>();
|
||||
const count = 5000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
snowflakes.add(generator.generate());
|
||||
}
|
||||
expect(snowflakes.size).toBe(count);
|
||||
});
|
||||
|
||||
it('should remain monotonic when the clock moves backwards', () => {
|
||||
const baseTime = Number(FLUXER_EPOCH) + 1000;
|
||||
const times = [baseTime + 2, baseTime + 1, baseTime + 3];
|
||||
let index = 0;
|
||||
|
||||
const generator = new SnowflakeGenerator({
|
||||
workerId: 1,
|
||||
now: () => {
|
||||
const nextIndex = Math.min(index, times.length - 1);
|
||||
index += 1;
|
||||
return times[nextIndex];
|
||||
},
|
||||
});
|
||||
|
||||
const first = generator.generate();
|
||||
const second = generator.generate();
|
||||
expect(second).toBeGreaterThan(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSnowflake', () => {
|
||||
beforeEach(() => {
|
||||
resetDefaultSnowflakeGenerator();
|
||||
});
|
||||
|
||||
it('should generate a snowflake with default worker ID', () => {
|
||||
const snowflake = generateSnowflake();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(0);
|
||||
});
|
||||
|
||||
it('should generate a snowflake with specified worker ID', () => {
|
||||
const snowflake = generateSnowflake(7);
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(7);
|
||||
});
|
||||
|
||||
it('should generate unique snowflakes with same worker ID', () => {
|
||||
const snowflakes = new Set<bigint>();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
snowflakes.add(generateSnowflake());
|
||||
}
|
||||
expect(snowflakes.size).toBe(100);
|
||||
});
|
||||
|
||||
it('should use the same default generator when worker ID is not provided', () => {
|
||||
const snowflake1 = generateSnowflake();
|
||||
const snowflake2 = generateSnowflake();
|
||||
expect(snowflake2).toBeGreaterThan(snowflake1);
|
||||
});
|
||||
|
||||
it('should create new generator when worker ID is provided', () => {
|
||||
const snowflake1 = generateSnowflake(5);
|
||||
const snowflake2 = generateSnowflake(5);
|
||||
const parsed1 = parseSnowflake(snowflake1);
|
||||
const parsed2 = parseSnowflake(snowflake2);
|
||||
expect(parsed1.workerId).toBe(5);
|
||||
expect(parsed2.workerId).toBe(5);
|
||||
});
|
||||
|
||||
it('should support options-based generation', () => {
|
||||
const snowflake = generateSnowflake({workerId: 9});
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(9);
|
||||
});
|
||||
|
||||
it('should use configured default generator options', () => {
|
||||
setDefaultSnowflakeGenerator({workerId: 321});
|
||||
const snowflake = generateSnowflake();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(321);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnowflakeGenerator', () => {
|
||||
it('should create a configured generator from options', () => {
|
||||
const generator = createSnowflakeGenerator({workerId: 11});
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnowflake', () => {
|
||||
it('should create a snowflake with explicit worker and sequence', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflake({
|
||||
timestamp,
|
||||
workerId: 3,
|
||||
sequence: 77,
|
||||
});
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.timestamp.getTime()).toBe(timestamp);
|
||||
expect(parsed.workerId).toBe(3);
|
||||
expect(parsed.sequence).toBe(77);
|
||||
});
|
||||
|
||||
it('should throw error for out-of-range sequence', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
expect(() =>
|
||||
createSnowflake({
|
||||
timestamp,
|
||||
sequence: 4096,
|
||||
}),
|
||||
).toThrow('Sequence must be between 0 and 4095');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSnowflakeFromTimestamp', () => {
|
||||
it('should create a snowflake from a numeric timestamp', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(timestamp);
|
||||
});
|
||||
|
||||
it('should create a snowflake from a bigint timestamp', () => {
|
||||
const timestamp = FLUXER_EPOCH + 1000000n;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(Number(timestamp));
|
||||
});
|
||||
|
||||
it('should create a snowflake with default worker ID of 0', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(0);
|
||||
});
|
||||
|
||||
it('should create a snowflake with specified worker ID', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp, 100);
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(100);
|
||||
});
|
||||
|
||||
it('should create a snowflake with sequence of 0', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw error for timestamp before epoch', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) - 1;
|
||||
expect(() => createSnowflakeFromTimestamp(timestamp)).toThrow('Timestamp must be on or after the Fluxer epoch');
|
||||
});
|
||||
|
||||
it('should accept timestamp exactly at epoch', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH);
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(timestamp);
|
||||
});
|
||||
|
||||
it('should throw error for invalid worker ID', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
expect(() => createSnowflakeFromTimestamp(timestamp, -1)).toThrow('Worker ID must be between 0 and 1023');
|
||||
expect(() => createSnowflakeFromTimestamp(timestamp, 1024)).toThrow('Worker ID must be between 0 and 1023');
|
||||
});
|
||||
|
||||
it('should create different snowflakes for different worker IDs at same timestamp', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake1 = createSnowflakeFromTimestamp(timestamp, 0);
|
||||
const snowflake2 = createSnowflakeFromTimestamp(timestamp, 1);
|
||||
expect(snowflake1).not.toBe(snowflake2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snowflakeToDate', () => {
|
||||
it('should extract date from a generated snowflake', () => {
|
||||
const before = Date.now();
|
||||
const snowflake = generateSnowflake();
|
||||
const after = Date.now();
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(date.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should extract correct date from a snowflake created from timestamp', () => {
|
||||
const expectedTimestamp = Number(FLUXER_EPOCH) + 86400000;
|
||||
const snowflake = createSnowflakeFromTimestamp(expectedTimestamp);
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(expectedTimestamp);
|
||||
});
|
||||
|
||||
it('should handle snowflake at epoch', () => {
|
||||
const snowflake = 0n;
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(Number(FLUXER_EPOCH));
|
||||
});
|
||||
|
||||
it('should handle large snowflake values', () => {
|
||||
const futureTimestamp = Number(FLUXER_EPOCH) + 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
const snowflake = createSnowflakeFromTimestamp(futureTimestamp);
|
||||
const date = snowflakeToDate(snowflake);
|
||||
expect(date.getTime()).toBe(futureTimestamp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSnowflake', () => {
|
||||
it('should parse all components of a snowflake', () => {
|
||||
const workerId = 42;
|
||||
const generator = new SnowflakeGenerator(workerId);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed).toHaveProperty('timestamp');
|
||||
expect(parsed).toHaveProperty('workerId');
|
||||
expect(parsed).toHaveProperty('sequence');
|
||||
expect(parsed.timestamp).toBeInstanceOf(Date);
|
||||
expect(typeof parsed.workerId).toBe('number');
|
||||
expect(typeof parsed.sequence).toBe('number');
|
||||
});
|
||||
|
||||
it('should extract correct worker ID', () => {
|
||||
const workerId = 777;
|
||||
const generator = new SnowflakeGenerator(workerId);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(workerId);
|
||||
});
|
||||
|
||||
it('should extract correct timestamp', () => {
|
||||
const before = Date.now();
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflake = generator.generate();
|
||||
const after = Date.now();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.timestamp.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(parsed.timestamp.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('should extract sequence starting from 0', () => {
|
||||
const timestamp = Number(FLUXER_EPOCH) + 1000000;
|
||||
const snowflake = createSnowflakeFromTimestamp(timestamp);
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.sequence).toBe(0);
|
||||
});
|
||||
|
||||
it('should parse snowflake with maximum worker ID', () => {
|
||||
const generator = new SnowflakeGenerator(1023);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(1023);
|
||||
});
|
||||
|
||||
it('should correctly parse a manually constructed snowflake', () => {
|
||||
const relativeTimestamp = 1000000n;
|
||||
const workerId = 500n;
|
||||
const sequence = 100n;
|
||||
const snowflake = (relativeTimestamp << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.timestamp.getTime()).toBe(Number(FLUXER_EPOCH) + Number(relativeTimestamp));
|
||||
expect(parsed.workerId).toBe(Number(workerId));
|
||||
expect(parsed.sequence).toBe(Number(sequence));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidSnowflake', () => {
|
||||
describe('valid snowflakes', () => {
|
||||
it('should return true for a generated snowflake', () => {
|
||||
const snowflake = generateSnowflake();
|
||||
expect(isValidSnowflake(snowflake)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for a snowflake created from timestamp', () => {
|
||||
const snowflake = createSnowflakeFromTimestamp(Date.now());
|
||||
expect(isValidSnowflake(snowflake)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for snowflake at epoch (0n)', () => {
|
||||
expect(isValidSnowflake(0n)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for snowflake with maximum worker ID', () => {
|
||||
const generator = new SnowflakeGenerator(1023);
|
||||
const snowflake = generator.generate();
|
||||
expect(isValidSnowflake(snowflake)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid snowflakes', () => {
|
||||
it('should return false for non-bigint values', () => {
|
||||
expect(isValidSnowflake(123)).toBe(false);
|
||||
expect(isValidSnowflake('123456789')).toBe(false);
|
||||
expect(isValidSnowflake(null)).toBe(false);
|
||||
expect(isValidSnowflake(undefined)).toBe(false);
|
||||
expect(isValidSnowflake({})).toBe(false);
|
||||
expect(isValidSnowflake([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for negative bigint', () => {
|
||||
expect(isValidSnowflake(-1n)).toBe(false);
|
||||
expect(isValidSnowflake(-1000000n)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for snowflake with timestamp before epoch', () => {
|
||||
const invalidSnowflake = -1n << TIMESTAMP_SHIFT;
|
||||
expect(isValidSnowflake(invalidSnowflake)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for snowflake with timestamp too far in the future', () => {
|
||||
const farFutureTimestamp = BigInt(Date.now() + 86400000 + 1000) - FLUXER_EPOCH;
|
||||
const invalidSnowflake = farFutureTimestamp << TIMESTAMP_SHIFT;
|
||||
expect(isValidSnowflake(invalidSnowflake)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for snowflake with timestamp within 24 hours in the future', () => {
|
||||
const nearFutureTimestamp = BigInt(Date.now() + 3600000) - FLUXER_EPOCH;
|
||||
const validSnowflake = nearFutureTimestamp << TIMESTAMP_SHIFT;
|
||||
expect(isValidSnowflake(validSnowflake)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('snowflake bit structure', () => {
|
||||
it('should use 22 bits for worker ID and sequence combined', () => {
|
||||
const totalNonTimestampBits = WORKER_ID_BITS + SEQUENCE_BITS;
|
||||
expect(totalNonTimestampBits).toBe(22n);
|
||||
});
|
||||
|
||||
it('should use 12 bits for sequence (max 4095)', () => {
|
||||
expect(MAX_SEQUENCE).toBe(4095n);
|
||||
});
|
||||
|
||||
it('should use 10 bits for worker ID (max 1023)', () => {
|
||||
expect(MAX_WORKER_ID).toBe(1023n);
|
||||
});
|
||||
|
||||
it('should preserve all components through encode/decode cycle', () => {
|
||||
const relativeTimestamp = 123456789n;
|
||||
const workerId = 789n;
|
||||
const sequence = 3456n;
|
||||
const snowflake = (relativeTimestamp << TIMESTAMP_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.timestamp.getTime()).toBe(Number(FLUXER_EPOCH) + Number(relativeTimestamp));
|
||||
expect(parsed.workerId).toBe(Number(workerId));
|
||||
expect(parsed.sequence).toBe(Number(sequence));
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueness guarantees', () => {
|
||||
it('should generate unique snowflakes across multiple generators with different worker IDs', () => {
|
||||
const generators = [new SnowflakeGenerator(0), new SnowflakeGenerator(1), new SnowflakeGenerator(2)];
|
||||
const snowflakes = new Set<bigint>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
for (const generator of generators) {
|
||||
snowflakes.add(generator.generate());
|
||||
}
|
||||
}
|
||||
expect(snowflakes.size).toBe(3000);
|
||||
});
|
||||
|
||||
it('should maintain uniqueness under high-speed generation', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
const snowflakes = new Set<bigint>();
|
||||
const count = 10000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
snowflakes.add(generator.generate());
|
||||
}
|
||||
expect(snowflakes.size).toBe(count);
|
||||
});
|
||||
|
||||
it('should generate monotonically increasing snowflakes', () => {
|
||||
const generator = new SnowflakeGenerator(1);
|
||||
let previous = 0n;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const current = generator.generate();
|
||||
expect(current).toBeGreaterThan(previous);
|
||||
previous = current;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and boundaries', () => {
|
||||
it('should handle worker ID 0', () => {
|
||||
const generator = new SnowflakeGenerator(0);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle worker ID 1023 (maximum)', () => {
|
||||
const generator = new SnowflakeGenerator(1023);
|
||||
const snowflake = generator.generate();
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(1023);
|
||||
});
|
||||
|
||||
it('should correctly extract components from snowflake with all maximum values', () => {
|
||||
const maxRelativeTimestamp = (1n << 41n) - 1n;
|
||||
const maxWorkerId = MAX_WORKER_ID;
|
||||
const maxSequence = MAX_SEQUENCE;
|
||||
const maxSnowflake = (maxRelativeTimestamp << TIMESTAMP_SHIFT) | (maxWorkerId << WORKER_ID_SHIFT) | maxSequence;
|
||||
const parsed = parseSnowflake(maxSnowflake);
|
||||
expect(parsed.workerId).toBe(Number(maxWorkerId));
|
||||
expect(parsed.sequence).toBe(Number(maxSequence));
|
||||
});
|
||||
|
||||
it('should correctly extract components from snowflake with all zero values', () => {
|
||||
const snowflake = 0n;
|
||||
const parsed = parseSnowflake(snowflake);
|
||||
expect(parsed.workerId).toBe(0);
|
||||
expect(parsed.sequence).toBe(0);
|
||||
expect(parsed.timestamp.getTime()).toBe(Number(FLUXER_EPOCH));
|
||||
});
|
||||
});
|
||||
5
packages/snowflake/tsconfig.json
Normal file
5
packages/snowflake/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
44
packages/snowflake/vitest.config.ts
Normal file
44
packages/snowflake/vitest.config.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user