Skip to content
 
 

Latest commit

 

History

228 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ulid-java

A Java port of the TypeScript ulid package, v3.0.2.

Universally-unique, lexicographically-sortable identifiers.

The goal of this port is behavioural equivalence, not idiomatic rewriting: every function, class, constant, error code, error message, edge case and side effect has a one-to-one Java counterpart.

Build & test

mvn test          # runs the migrated suite + the differential suite
mvn package       # produces target/ulid-java-3.0.2.jar (executable CLI)

Requires JDK 17+.

Usage

import io.github.ulid.ULID;
import io.github.ulid.ULIDFactory;

ULID.ulid();                                  // "01HNZXD07M5CEN5XA66EMZSRZW"
ULID.encodeTime(1469918176385L);              // "01ARYZ6S41"
ULID.decodeTime("01ARYZ6S41TSV4RRFFQ69G5FAV"); // 1469918176385L
ULID.isValid("01HNZX8JGFACFA36RBXDHEQN6E");   // true

ULIDFactory factory = ULID.monotonicFactory();
factory.create();                              // monotonically increasing
factory.create(1469918176385L);

ULID.ulidToUUID("01ARYZ6S41TSV4RRFFQ69G5FAV"); // "01563DF3-6481-D676-4C61-EFB99302BD5B"
ULID.uuidToULID("0195C9A4-2E32-C014-5F4F-A7CEF5BE83D5");

ULID.fixULIDBase32("oLARYZ6-S41TSV4RRF-FQ69G5FAV");
ULID.incrementBase32("A109C");                 // "A109D"

CLI:

java -jar target/ulid-java-3.0.2.jar --count 5

File mapping

TypeScript Java Notes
source/index.ts ULID.java Public facade / re-exports
source/ulid.ts UlidCore.java Named UlidCore because case-insensitive filesystems cannot hold both ULID.java and Ulid.java
source/crockford.ts Crockford.java
source/uuid.ts UuidConvert.java
source/utils.ts Utils.java Plus JS-semantics helpers
source/constants.ts Constants.java
source/error.ts ULIDError.java, ULIDErrorCode.java
source/types.ts PRNG.java, ULIDFactory.java ULID/UUID string aliases become String
source/cli.ts Cli.java
source/stub.ts (omitted) export default undefined — a bundler placeholder with no runtime behaviour

Type mapping

TypeScript Java Rationale
number (timestamps) double primary, long overloads double keeps the NaN / non-integer validation branches reachable
string String
Uint8Array byte[] Read back via & 0xFF for unsigned semantics
PRNG = () => number @FunctionalInterface PRNG
ULIDFactory = (seedTime?) => ULID @FunctionalInterface ULIDFactory null models undefined
string enum ULIDErrorCode enum with value() toString() returns the literal so message interpolation matches
class ULIDError extends Error extends RuntimeException JS errors are unchecked

Behavioural details deliberately preserved

These are the subtle points where a naive port silently diverges.

  1. incrementBase32 loop boundary. The TS loop is while (!done && index-- >= 0). The post-decrement runs before the body, so the body reads output[index - 1]. When index reaches 0 the comparison still passes while index becomes -1, making output[-1] undefined; ENCODING.indexOf(undefined) is -1. A saturated input such as "ZZZ" therefore exits via the B32_ENC_INVALID ("Incorrectly encoded string") throw — the trailing "Failed incrementing string" throw is unreachable. Both throws are ported; the reachable one is asserted by the tests.

  2. Falsy seedTime. !seedTime || isNaN(seedTime) means 0, -0, NaN, null and undefined all fall back to Date.now(). Passing 0 does not encode epoch zero.

  3. ECMA-262 number stringification. Error messages interpolate the raw number. Java's Double.toString switches to exponential notation at 1e7, JavaScript at 1e21, so 1469918176385.5 would render as 1.4699181763855E12. Utils.jsNumberToString implements the ECMA-262 §6.1.6.1.20 algorithm, including computing the genuinely shortest round-tripping decimal — Double.toString is contractually required to emit at least one fractional digit and so renders Double.MIN_VALUE as 4.9E-324 rather than JavaScript's 5e-324.

  4. Locale-independent case folding. All toUpperCase() calls use Locale.ROOT. The JVM default locale would corrupt i/I under a Turkish locale, silently breaking Crockford decoding.

  5. 32-bit bitwise semantics. JS coerces to int32 for |, <<, >>>. Java's int and >>> reproduce this exactly; Uint8Array reads use & 0xFF.

  6. String.charAt vs bracket access. charAt(oob) returns ""; str[oob] returns undefined. Modelled by Utils.jsCharAt and Utils.jsCharacterAt.

  7. parseInt(x, 10) consumes only leading digits, so --count 2abc yields 2.

  8. detectPRNG. The TS original probes window/self/global for crypto.getRandomValues, falling back to Node's crypto.randomBytes(1).readUInt8() / 256. The JVM has a single cryptographic source, so this returns the exact numeric equivalent of that fallback: SecureRandom unsigned byte / 256, yielding [0, 0.99609375].

Verification

1. Migrated test suite — all 21 original vitest cases, same inputs, assertions and expected outputs. The two describe blocks that share a stubbed factory across sequential it calls are modelled as ordered @Nested classes with a per-class lifecycle, preserving the mutation order the originals depend on.

Original spec Java test Cases
test/node/ulid.spec.ts UlidTest 14
test/node/crockford.spec.ts CrockfordTest 5
test/node/uuid.spec.ts UuidTest 2

A further 56 tests (PublicApiTest, InternalsTest, CliTest, UnreachabilityTest) cover the surface the original suite never reaches — isValid, every error code, the CLI argument parser and the JS-semantics helpers — bringing the suite to 77 tests at 99.0% line / 98.4% branch / 100% method coverage.

Three statements remain uncovered by design: the trailing "Failed incrementing string" throw in Crockford, its loop-exhaustion break, and the Unexpected throw in UuidConvert. Each mirrors a statement that is equally unreachable in the TypeScript original and equally uncovered by the original's own suite. UnreachabilityTest measures the claim rather than asserting it: it drives the enclosing functions over their whole reachable input space and fails if a dead branch ever fires.

2. Differential suite (DifferentialTest) — tools/generate.mjs evaluates a deterministic corpus of 3,075 cases against the real built npm package and records the reference results into src/test/resources/differential/cases.tsv; the Java port replays the identical corpus and compares every result, including exact error-message text, byte-for-byte. Both sides share an identical MINSTD PRNG so random paths are reproducible.

The shipped corpus is a sample of a larger 18,485-case sweep; the sample was confirmed to reproduce the larger run's JaCoCo line and branch coverage exactly, class by class. Raise the loop bounds in tools/generate.mjs to regenerate the full sweep.

Coverage includes boundary timestamps (0, TIME_MAX ± 1), NaNInfinity, non-integral and subnormal doubles, every encodeTime length, malformed/invalid ULIDs and UUIDs, deep incrementBase32 carry chains, monotonic saturation and rollover, and UUID↔ULID round-trips.

To regenerate:

node tools/generate.mjs && mvn test

# if the reference repository lives elsewhere:
ULID_REFERENCE_DIST=/path/to/ulid_javascript/dist/node/index.js node tools/generate.mjs

3. Interop — 500 ULIDs generated by Java were parsed by the TypeScript implementation; decodeTime, isValid, ulidToUUID and uuidToULID round-trips all agreed.

4. CLI parity — 12 argument scenarios produce identical output counts.

5. Cross-process parity (qc_parity/QcParity.java) — mirrors qc_parity/main.mjs in the source repository, driving the public API through 92 deterministic observable behaviours and printing one key=value line each. Both harnesses run in their own containers and their stdout is diffed; the output is byte-identical. This is the check the migration QC behaviour gate consumes via qc_fixtures.json in the source repository.

About

Universally Unique Lexicographically Sortable Identifier

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages