diff --git a/CLAUDE.md b/CLAUDE.md
index ec6231b..67ce573 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -163,10 +163,11 @@ package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in
(`.github/workflows/ci.yml`) and the release workflow
(`.github/workflows/release.yml`). The package holds time-based and
deterministic generation (`Uxid.generate` and `UxidOptions`), decoding
-(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks
-(`Uxid.isValid` and `Uxid.isDeterministic`). The monotonic generator and the
-vendored corpus arrive with later changes; until they do, the sentences in this file about the
-1.0.0 package and its corpus describe the target, not the current tree.
+(`Uxid.decode`, `DecodeOptions` and `DecodeResult`), the validity checks
+(`Uxid.isValid` and `Uxid.isDeterministic`) and the monotonic generator
+(`Monotonic`, with its `DrawSource`). The vendored corpus arrives with a later
+change; until it does, the sentences in this file about the 1.0.0 package and
+its corpus describe the target, not the current tree.
- **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first
release on Maven Central.
diff --git a/changelog.d/uxjv-5tk.md b/changelog.d/uxjv-5tk.md
new file mode 100644
index 0000000..397dfd4
--- /dev/null
+++ b/changelog.d/uxjv-5tk.md
@@ -0,0 +1,6 @@
+### Added
+
+- `Monotonic` is a generator the caller holds whose ids for one prefix and random width increase strictly, also within one millisecond and when the clock moves back: `generate()` and `generate(UxidOptions)` keep the last time and random value per prefix and random width, step the random field by a value drawn uniformly from 1 to 2^(4w) within a millisecond, and move the time one millisecond forward with a fresh seed when the field overflows. A monotonic `xs` uses compact time unless the options set the time form.
+- One `Monotonic` may be shared between threads: a lock inside the instance orders the calls, so its ids still increase strictly. Nothing is kept in a static field, and two generators never affect each other.
+- `DrawSource` replaces a generator's seeds and steps to replay known draws; by default they come from each call's byte source, the step masked from its bytes with no modulo bias.
+- `Monotonic.generate(UxidOptions)` refuses a `from` input with `from_with_monotonic` and fixed random bytes with `rand_with_monotonic`. A call that throws leaves the generator's state as it was.
diff --git a/src/main/java/systems/riddler/uxid/DrawSource.java b/src/main/java/systems/riddler/uxid/DrawSource.java
new file mode 100644
index 0000000..c77e535
--- /dev/null
+++ b/src/main/java/systems/riddler/uxid/DrawSource.java
@@ -0,0 +1,36 @@
+package systems.riddler.uxid;
+
+/**
+ * Where a {@link Monotonic} generator takes its random values from: a fresh random field, the seed,
+ * and the step that moves the field on within a millisecond.
+ *
+ *
A generator built with {@link Monotonic#Monotonic()} has no draw source of its own: it draws
+ * from each call's {@link UxidOptions.Builder#byteSource(ByteSource) byte source}, as {@link
+ * Monotonic} describes. A draw source of your own, passed to {@link
+ * Monotonic#Monotonic(DrawSource)}, replaces those draws, and the byte source is then never called.
+ * It exists to replay known values, as a test or a conformance case does, and is never a source of
+ * randomness for real ids: a monotonic id is exactly as hard to guess as its draws.
+ *
+ *
A generator calls its draw source with its lock held, so a draw source used by one generator
+ * is called by one thread at a time; one shared by several generators must be safe for that.
+ */
+public interface DrawSource {
+
+ /**
+ * Returns a fresh random value for a random field of {@code width} bytes: a new array of exactly
+ * {@code width} bytes, read as one big-endian unsigned integer.
+ *
+ * @param width the random field's width in bytes, from 1 to 10
+ * @return a new array of {@code width} bytes
+ */
+ byte[] seed(int width);
+
+ /**
+ * Returns a step drawn uniformly from 1 to 2^(4 x {@code width}) inclusive, half the random
+ * field's bit width.
+ *
+ * @param width the random field's width in bytes, from 1 to 10
+ * @return the step, from 1 to 2^(4 x {@code width})
+ */
+ long step(int width);
+}
diff --git a/src/main/java/systems/riddler/uxid/Monotonic.java b/src/main/java/systems/riddler/uxid/Monotonic.java
new file mode 100644
index 0000000..cbc13da
--- /dev/null
+++ b/src/main/java/systems/riddler/uxid/Monotonic.java
@@ -0,0 +1,279 @@
+package systems.riddler.uxid;
+
+import java.math.BigInteger;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * A monotonic generator: for one prefix and random width, the time and random value of each id it
+ * makes are greater than the last one's, also when several fall in one millisecond and when the
+ * clock moves back, so among ids of one letter case, delimiter and time form each sorts after the
+ * last as a string. It follows the "Monotonic generation" rule of the wire format. A monotonic id
+ * is an ordinary time-based id of its size and length: nothing in it says how it was made, and
+ * {@link Uxid#decode(String)} reads it as any other.
+ *
+ *
Each call takes a {@link UxidOptions} as {@link Uxid#generate(UxidOptions)} does, and the
+ * generator keeps its state per prefix and per random width: for each such pair it has seen, the
+ * last time {@code T} it gave and the last random value {@code R}, the random field read as one
+ * big-endian unsigned integer of {@code w} bytes. For a call at time {@code now} (the options'
+ * time, or {@link System#currentTimeMillis()} read at the call):
+ *
+ *
The draws come from the {@link DrawSource} the generator was built with, or, for a generator
+ * built with {@link #Monotonic()}, from each call's {@link
+ * UxidOptions.Builder#byteSource(ByteSource) byte source}: a seed is {@code w} bytes, read
+ * big-endian, and a step is the low {@code 4w} bits of {@code ceil(w / 2)} bytes, read big-endian,
+ * plus one. The range of a step holds a power of two values, so the masked bits are exactly uniform
+ * over it, with no modulo bias and nothing redrawn.
+ *
+ *
The step is not a plus-one counter: consecutive ids in a millisecond are spread over a window
+ * of 2^(4w) values. That stops trivial enumeration, but it is a mitigation, not unpredictability,
+ * and a small random field stays small: a standard {@code s} id has 16 random bits. A compact time
+ * holds only the low 40 bits of the time, so compact ids keep their order only within one 2^40 ms
+ * window, as every compact id does.
+ *
+ *
At size {@link Size#XS} the generator uses compact time unless the options set the time form,
+ * because a standard {@code xs} has no random field to step and a compact {@code xs} has a one-byte
+ * field. The random width (the size's, or the options' {@link UxidOptions.Builder#randSize(int)
+ * random width}) must be at least one byte, so an {@code xs} with an explicit standard time and no
+ * random width is refused.
+ *
+ *
state = new HashMap<>();
+
+ /**
+ * Builds a generator that draws its seeds and steps from each call's {@link
+ * UxidOptions.Builder#byteSource(ByteSource) byte source}, by default {@link
+ * ByteSource#secureRandom()}.
+ */
+ public Monotonic() {
+ this.draws = null;
+ }
+
+ /**
+ * Builds a generator that takes its seeds and steps from {@code draws}; the options' byte source
+ * is never called. This replays known draws, as a test or a conformance case does; ids built from
+ * known draws are exactly as guessable as those draws, so production code uses {@link
+ * #Monotonic()}.
+ *
+ * @param draws the source of every seed and step
+ * @throws NullPointerException when {@code draws} is null
+ */
+ public Monotonic(DrawSource draws) {
+ this.draws = Objects.requireNonNull(draws, "draws");
+ }
+
+ /**
+ * Generates the next id with every default: no prefix, size {@link Size#XL} with a standard time
+ * read from the clock now, lower case, and draws from {@link ByteSource#secureRandom()} unless
+ * the generator has a draw source.
+ *
+ * @return a 26-character id
+ * @throws IllegalArgumentException when the clock reads outside 0 to 2^48 - 1 milliseconds, or
+ * the field overflows in millisecond 2^48 - 1
+ * @throws IllegalStateException when a draw answers outside its range
+ */
+ public String generate() {
+ return generate(UxidOptions.builder().build());
+ }
+
+ /**
+ * Generates the next id for the options' prefix and random width, under the rule the class
+ * describes. The options' prefix, size, random width, time form, letter case, delimiter, time and
+ * byte source mean what they mean to {@link Uxid#generate(UxidOptions)}, with the same defaults,
+ * except that an {@link Size#XS} with no time form set uses compact time.
+ *
+ * A call that throws leaves the generator as it was (see the class description).
+ *
+ * @param options the request
+ * @return the id
+ * @throws UxidException with {@link ErrorCode#FROM_WITH_MONOTONIC} when the options carry a
+ * {@link UxidOptions.Builder#from(String) from} input, since a deterministic id has no random
+ * field to step; {@link ErrorCode#RAND_WITH_MONOTONIC} for fixed {@link
+ * UxidOptions.Builder#rand(byte[]) random bytes}, since the generator chooses the field;
+ * {@link ErrorCode#RAND_SIZE_TOO_LARGE} for a random width above 10 bytes; {@link
+ * ErrorCode#COMPACT_TIME_OUT_OF_RANGE} for a compact time the id would carry whose first
+ * character would be the deterministic marker, also when that time is the millisecond after
+ * {@code T}, reached by an overflow. The first two are checked in that order before anything
+ * else.
+ * @throws IllegalArgumentException when the random width is 0 bytes (an {@code xs} with an
+ * explicit standard time and no random width, or a random width of 0); when the clock reads
+ * outside 0 to 2^48 - 1 milliseconds; or when the field overflows in millisecond 2^48 - 1,
+ * the last a time field holds
+ * @throws IllegalStateException when a byte source returns null or a count of bytes other than
+ * the one asked for, or a draw source answers a seed of the wrong length or a step outside 1
+ * to 2^(4w)
+ * @throws NullPointerException when {@code options} is null
+ */
+ public String generate(UxidOptions options) {
+ if (options.fromUtf8() != null) {
+ throw new UxidException(
+ ErrorCode.FROM_WITH_MONOTONIC,
+ "a monotonic generator makes no deterministic id: it has no random field to step");
+ }
+ if (options.hasRand()) {
+ throw new UxidException(
+ ErrorCode.RAND_WITH_MONOTONIC,
+ "a monotonic generator takes no fixed random bytes: it chooses the random field");
+ }
+ Boolean explicitCompact = options.compactTime();
+ boolean compact =
+ explicitCompact == null ? options.size() == Size.XS : explicitCompact.booleanValue();
+ Integer explicitWidth = options.randSize();
+ int width = explicitWidth == null ? options.size().randBytes(compact) : explicitWidth;
+ if (width > Uxid.MAX_RAND_BYTES) {
+ throw new UxidException(
+ ErrorCode.RAND_SIZE_TOO_LARGE,
+ "the random field is at most " + Uxid.MAX_RAND_BYTES + " bytes, got " + width);
+ }
+ if (width == 0) {
+ throw new IllegalArgumentException(
+ "a monotonic id needs a random field to step, but this request's random width is 0"
+ + " bytes; leave the time form unset at size xs, or set a random width from 1 to 10");
+ }
+
+ lock.lock();
+ try {
+ Long fixedTime = options.time();
+ long now =
+ fixedTime == null ? UxidOptions.requireTime(System.currentTimeMillis()) : fixedTime;
+ Key key = new Key(options.prefix(), width);
+ Last last = state.get(key);
+ Last next;
+ if (last != null && now <= last.time()) {
+ BigInteger value = last.value().add(BigInteger.valueOf(step(options, width)));
+ if (value.bitLength() <= 8 * width) {
+ next = new Last(last.time(), value);
+ } else if (last.time() == UxidOptions.MAX_TIME) {
+ throw new IllegalArgumentException(
+ "the random field overflowed in millisecond 2^48 - 1, the last a time field holds");
+ } else {
+ next = new Last(last.time() + 1, seed(options, width));
+ }
+ } else {
+ next = new Last(now, seed(options, width));
+ }
+
+ String id = format(options, compact, width, next);
+ state.put(key, next);
+ return id;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private static String format(UxidOptions options, boolean compact, int width, Last next) {
+ long compactTime = next.time() & ((1L << 40) - 1);
+ if (compact && compactTime >>> 35 == MARKER) {
+ throw new UxidException(
+ ErrorCode.COMPACT_TIME_OUT_OF_RANGE,
+ "a compact time from "
+ + next.time()
+ + " ms would start with the deterministic marker; use a standard time");
+ }
+ StringBuilder id = new StringBuilder(options.prefix().length() + 30);
+ if (!options.prefix().isEmpty()) {
+ id.append(options.prefix()).append(options.delimiter());
+ }
+ if (compact) {
+ Base32.appendInteger(id, compactTime, 8, options.letterCase());
+ } else {
+ Base32.appendInteger(id, next.time(), 10, options.letterCase());
+ }
+ Base32.appendBytes(id, toBytes(next.value(), width), options.letterCase());
+ return id.toString();
+ }
+
+ /** A fresh random value for a field of {@code width} bytes. */
+ private BigInteger seed(UxidOptions options, int width) {
+ byte[] bytes =
+ draws == null
+ ? Uxid.draw(options.byteSource(), width)
+ : checkSeed(draws.seed(width), width);
+ return new BigInteger(1, bytes);
+ }
+
+ /** A step from 1 to 2^(4w), uniform over it. */
+ private long step(UxidOptions options, int width) {
+ int bits = 4 * width;
+ long max = 1L << bits;
+ if (draws == null) {
+ byte[] bytes = Uxid.draw(options.byteSource(), (width + 1) / 2);
+ long drawn = 0;
+ for (byte b : bytes) {
+ drawn = (drawn << 8) | (b & 0xff);
+ }
+ return (drawn & (max - 1)) + 1;
+ }
+ long step = draws.step(width);
+ if (step < 1 || step > max) {
+ throw new IllegalStateException(
+ "the draw source answered the step " + step + ", outside 1 to 2^" + bits);
+ }
+ return step;
+ }
+
+ private static byte[] checkSeed(byte[] seed, int width) {
+ if (seed == null || seed.length != width) {
+ throw new IllegalStateException(
+ "the draw source answered a seed of "
+ + (seed == null ? "null" : seed.length + " bytes")
+ + " for a field of "
+ + width);
+ }
+ return seed.clone();
+ }
+
+ /** The {@code width}-byte big-endian form of {@code value}, which is below 2^(8 x width). */
+ private static byte[] toBytes(BigInteger value, int width) {
+ byte[] raw = value.toByteArray();
+ byte[] bytes = new byte[width];
+ int copy = Math.min(raw.length, width);
+ System.arraycopy(raw, raw.length - copy, bytes, width - copy, copy);
+ return bytes;
+ }
+
+ /** The pair the state is kept for. */
+ private record Key(String prefix, int width) {}
+
+ /** The last id's time and random value for one pair. */
+ private record Last(long time, BigInteger value) {}
+}
diff --git a/src/main/java/systems/riddler/uxid/Uxid.java b/src/main/java/systems/riddler/uxid/Uxid.java
index 424b8d2..4127330 100644
--- a/src/main/java/systems/riddler/uxid/Uxid.java
+++ b/src/main/java/systems/riddler/uxid/Uxid.java
@@ -32,6 +32,9 @@
* #isValid(String, String, String)} and {@link #isDeterministic(String, String)} answer the two
* questions most callers ask of a string.
*
+ *
Ids that must increase strictly within one millisecond come from a {@link Monotonic}
+ * generator, an object the caller holds with its own state.
+ *
*
Every method is static and safe to call from several threads at once; the class holds no
* configuration.
*/
@@ -301,7 +304,8 @@ private static MessageDigest sha256() {
}
}
- private static byte[] draw(ByteSource source, int width) {
+ /** Draws {@code width} bytes from {@code source}, refusing an answer of any other length. */
+ static byte[] draw(ByteSource source, int width) {
byte[] bytes = source.nextBytes(width);
if (bytes == null || bytes.length != width) {
throw new IllegalStateException(
diff --git a/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java
new file mode 100644
index 0000000..78aaeac
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/MonotonicCorpusTest.java
@@ -0,0 +1,117 @@
+package systems.riddler.uxid;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/**
+ * Cases copied from the conformance corpus at uxid-ex v2.10.0 ({@code conformance/monotonic.json}
+ * and the monotonic refusals of {@code conformance/invalid.json}), each named by its case id. A
+ * list is written space-separated. The full corpus runner replaces this hand-picked copy.
+ */
+class MonotonicCorpusTest {
+
+ @ParameterizedTest(name = "{0}") // (sabotage: overflow keeps T -> the two overflow cases failed)
+ @CsvSource(
+ nullValues = "null",
+ value = {
+ "monotonic/xl-same-millisecond, usr, xl, false, 123456789, null, 1 1099511627776 42,"
+ + " 1700000000000 1700000000000 1700000000000 1700000000000,"
+ + " usr_01hf7yat0000000000003nqk8n usr_01hf7yat0000000000003nqk8p"
+ + " usr_01hf7yat0000000001003nqk8p usr_01hf7yat0000000001003nqka0",
+ "monotonic/m-clock-moves-back, usr, m, false, 1000, null, 5 1048576 7,"
+ + " 1700000000000 1699999999999 1699999999990 1700000000000,"
+ + " usr_01hf7yat00000000z8 usr_01hf7yat00000000zd usr_01hf7yat00000100zd"
+ + " usr_01hf7yat00000100zm",
+ "monotonic/s-overflow-bumps-time, usr, s, false, 65000, 1234, 200 250 100 7 3,"
+ + " 1700000000000 1700000000000 1700000000000 1700000000000 1700000000000"
+ + " 1700000000001,"
+ + " usr_01hf7yat00zqm0 usr_01hf7yat00ztr0 usr_01hf7yat00zyn0 usr_01hf7yat010k90"
+ + " usr_01hf7yat010kc1 usr_01hf7yat010ke0",
+ "monotonic/xs-compact-overflow-clock-back, usr, xs, true, 250, 9, 5 16,"
+ + " 1700000000000 1700000000000 1699999999999,"
+ + " usr_hf7yat00z2 usr_hf7yat00z7 usr_hf7yat0111",
+ "monotonic/l-compact-single, usr, l, true, 0, null, '', 1700000000000,"
+ + " usr_hf7yat000000000000000",
+ "monotonic/s-compact-no-prefix-largest-step, null, s, true, 0, null, 4096,"
+ + " 1700000000000 1700000000000, hf7yat0000000 hf7yat0000800",
+ })
+ void aMonotonicCaseGivesTheCorpusIds(
+ String id,
+ String prefix,
+ String size,
+ boolean compact,
+ long initialSeed,
+ Long overflowSeed,
+ String steps,
+ String times,
+ String expected) {
+ List seeds = new ArrayList<>(List.of(initialSeed));
+ if (overflowSeed != null) {
+ seeds.add(overflowSeed);
+ }
+ TestSources.Replay draws = new TestSources.Replay(seeds, longs(steps));
+ Monotonic generator = new Monotonic(draws);
+
+ List ids = new ArrayList<>();
+ for (long time : longs(times)) {
+ UxidOptions.Builder builder =
+ UxidOptions.builder()
+ .size(Size.parse(size))
+ .compactTime(compact)
+ .time(time)
+ .byteSource(TestSources.refusing());
+ if (prefix != null) {
+ builder.prefix(prefix);
+ }
+ ids.add(generator.generate(builder.build()));
+ }
+
+ assertEquals(Arrays.asList(expected.split(" ", -1)), ids, id);
+ assertTrue(draws.drained(), id + ": every recorded draw is drawn");
+ }
+
+ @Test // (sabotage: from check skipped with a prefix -> assertThrows saw draws)
+ void fromWithAMonotonicGeneratorIsRefused() {
+ UxidOptions options = UxidOptions.builder().prefix("usr").from("alice@example.com").build();
+ Monotonic generator = new Monotonic(TestSources.refusingDraws());
+
+ UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options));
+ assertEquals(
+ ErrorCode.FROM_WITH_MONOTONIC, refusal.code(), "invalid/generate-from-with-monotonic");
+ }
+
+ @Test // (sabotage: rand check skipped with a prefix -> assertThrows saw draws)
+ void fixedRandomBytesWithAMonotonicGeneratorAreRefused() {
+ UxidOptions options =
+ UxidOptions.builder()
+ .prefix("usr")
+ .rand(HexFormat.of().parseHex("0102"))
+ .size(Size.S)
+ .time(1_700_000_000_000L)
+ .build();
+ Monotonic generator = new Monotonic(TestSources.refusingDraws());
+
+ UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options));
+ assertEquals(
+ ErrorCode.RAND_WITH_MONOTONIC, refusal.code(), "invalid/generate-rand-with-monotonic");
+ }
+
+ private static List longs(String list) {
+ List values = new ArrayList<>();
+ for (String value : list.trim().split(" ", -1)) {
+ if (!value.isEmpty()) {
+ values.add(Long.parseLong(value));
+ }
+ }
+ return values;
+ }
+}
diff --git a/src/test/java/systems/riddler/uxid/MonotonicTest.java b/src/test/java/systems/riddler/uxid/MonotonicTest.java
new file mode 100644
index 0000000..fe4a207
--- /dev/null
+++ b/src/test/java/systems/riddler/uxid/MonotonicTest.java
@@ -0,0 +1,402 @@
+package systems.riddler.uxid;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+
+class MonotonicTest {
+ private static final long TIME = 1_700_000_000_000L;
+
+ /** The last compact time whose first character is not the marker: 31 x 2^35 - 1. */
+ private static final long LAST_COMPACT = 1_065_151_889_407L;
+
+ @Test // (sabotage: step multiplied by 0 -> the second id assertion failed)
+ void aCallInTheSameMillisecondAddsTheStepToTheLastValue() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(7L, 1L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).prefix("usr").size(Size.S).build();
+
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 100), generator.generate(options));
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 107), generator.generate(options));
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 108), generator.generate(options));
+ assertEquals(List.of("seed", "step", "step"), draws.calls);
+ }
+
+ @Test // (sabotage: now <= T read as now == T -> a seed was drawn, the replay ran dry)
+ void aClockThatMovesBackKeepsTheLastTime() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(3L));
+ Monotonic generator = new Monotonic(draws);
+
+ generator.generate(at(TIME).prefix("usr").size(Size.M).build());
+ assertEquals(
+ plain(at(TIME).prefix("usr").size(Size.M), 5, 103),
+ generator.generate(at(TIME - 50).prefix("usr").size(Size.M).build()));
+ assertEquals(List.of("seed", "step"), draws.calls);
+ }
+
+ @Test // (sabotage: now <= T + 1 -> a step was drawn, the replay ran dry)
+ void aLaterTimeTakesAFreshSeedAndTheNewTime() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(100L, 5L), List.of());
+ Monotonic generator = new Monotonic(draws);
+
+ generator.generate(at(TIME).prefix("usr").size(Size.S).build());
+ assertEquals(
+ plain(at(TIME + 1).prefix("usr").size(Size.S), 2, 5),
+ generator.generate(at(TIME + 1).prefix("usr").size(Size.S).build()));
+ assertEquals(List.of("seed", "seed"), draws.calls);
+ }
+
+ @Test // (sabotage: overflow keeps T -> the id assertion failed on the time)
+ void anOverflowMovesTheTimeOneMillisecondForwardWithAFreshSeed() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(250L, 9L), List.of(6L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build();
+
+ generator.generate(options);
+ assertEquals(
+ plain(at(TIME + 1).prefix("usr").size(Size.XS).compactTime(true), 1, 9),
+ generator.generate(options));
+ assertEquals(List.of("seed", "step", "seed"), draws.calls);
+ }
+
+ @Test // (sabotage: key without the prefix -> cus stepped, the replay ran dry)
+ void theStateIsKeptPerPrefixAndPerRandomWidth() {
+ TestSources.Replay draws =
+ new TestSources.Replay(List.of(1L, 2L, 3L, 4L), List.of(10L, 20L, 30L, 40L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions usrS = at(TIME).prefix("usr").size(Size.S).build();
+ UxidOptions cusS = at(TIME).prefix("cus").size(Size.S).build();
+ UxidOptions usrM = at(TIME).prefix("usr").size(Size.M).build();
+ UxidOptions usrWidth3 = at(TIME).prefix("usr").size(Size.S).randSize(3).build();
+
+ List ids = new ArrayList<>();
+ for (int round = 0; round < 2; round++) {
+ ids.add(generator.generate(usrS));
+ ids.add(generator.generate(cusS));
+ ids.add(generator.generate(usrM));
+ ids.add(generator.generate(usrWidth3));
+ }
+
+ List expected =
+ List.of(
+ plain(at(TIME).prefix("usr").size(Size.S), 2, 1),
+ plain(at(TIME).prefix("cus").size(Size.S), 2, 2),
+ plain(at(TIME).prefix("usr").size(Size.M), 5, 3),
+ plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 4),
+ plain(at(TIME).prefix("usr").size(Size.S), 2, 11),
+ plain(at(TIME).prefix("cus").size(Size.S), 2, 22),
+ plain(at(TIME).prefix("usr").size(Size.M), 5, 33),
+ plain(at(TIME).prefix("usr").size(Size.S).randSize(3), 3, 44));
+ assertEquals(expected, ids);
+ }
+
+ @Test // (sabotage: one static state map -> the second generator stepped, replay dry)
+ void twoGeneratorsKeepTheirOwnState() {
+ TestSources.Replay one = new TestSources.Replay(List.of(1L), List.of());
+ TestSources.Replay two = new TestSources.Replay(List.of(2L), List.of(1L));
+ UxidOptions options = at(TIME).prefix("usr").size(Size.S).build();
+
+ assertEquals(
+ plain(at(TIME).prefix("usr").size(Size.S), 2, 1), new Monotonic(one).generate(options));
+ assertEquals(
+ plain(at(TIME).prefix("usr").size(Size.S), 2, 2), new Monotonic(two).generate(options));
+ }
+
+ @Test // (sabotage: step mask dropped -> the compact s id assertion failed)
+ void theDefaultDrawsMaskTheByteSourceToTheStepRange() {
+ Monotonic s = new Monotonic();
+ UxidOptions sOptions =
+ UxidOptions.builder()
+ .time(TIME)
+ .prefix("usr")
+ .size(Size.S)
+ .compactTime(true)
+ .byteSource(answering("000000", "ffff"))
+ .build();
+ s.generate(sOptions);
+ assertEquals(
+ plain(at(TIME).prefix("usr").size(Size.S).compactTime(true), 3, 4096),
+ s.generate(sOptions));
+
+ Monotonic xs = new Monotonic();
+ UxidOptions xsOptions =
+ UxidOptions.builder()
+ .time(TIME)
+ .prefix("usr")
+ .size(Size.XS)
+ .byteSource(answering("00", "ff"))
+ .build();
+ xs.generate(xsOptions);
+ assertEquals(
+ plain(at(TIME).prefix("usr").size(Size.XS).compactTime(true), 1, 16),
+ xs.generate(xsOptions));
+ }
+
+ @Test // (sabotage: step drew w bytes -> the counts assertion failed)
+ void theDefaultDrawsAskTheByteSourceForTheSeedAndHalfTheWidth() {
+ TestSources.Counting source = new TestSources.Counting();
+ Monotonic generator = new Monotonic();
+ UxidOptions options =
+ UxidOptions.builder().time(TIME).prefix("usr").size(Size.M).byteSource(source).build();
+
+ generator.generate(options);
+ generator.generate(options);
+ assertEquals(List.of(5, 3), source.counts);
+ }
+
+ @Test // (sabotage: draw source dropped -> the refusing byte source was called)
+ void aDrawSourceReplacesTheByteSource() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(1L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).prefix("usr").size(Size.L).build();
+
+ generator.generate(options);
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.L), 7, 2), generator.generate(options));
+ }
+
+ @Test // (sabotage: compact default moved to xl -> assertDoesNotThrow failed)
+ void anXsWithNoTimeFormUsesCompactTime() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of());
+ UxidOptions options = at(TIME).prefix("usr").size(Size.XS).build();
+
+ Monotonic generator = new Monotonic(draws);
+
+ assertEquals("usr_hf7yat0001", assertDoesNotThrow(() -> generator.generate(options)));
+ }
+
+ @Test // (sabotage: width-0 check removed -> assertThrows saw the refusing draws)
+ void aRandomWidthOfZeroIsRefused() {
+ Monotonic generator = new Monotonic(TestSources.refusingDraws());
+ UxidOptions standardXs = at(TIME).size(Size.XS).compactTime(false).build();
+ UxidOptions zeroWidth = at(TIME).randSize(0).build();
+
+ assertThrows(IllegalArgumentException.class, () -> generator.generate(standardXs));
+ assertThrows(IllegalArgumentException.class, () -> generator.generate(zeroWidth));
+ }
+
+ @Test // (sabotage: width below 11 ignored -> assertDoesNotThrow failed)
+ void anExplicitWidthOnAStandardXsIsStepped() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(1L), List.of(2L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).size(Size.XS).compactTime(false).randSize(2).build();
+
+ assertDoesNotThrow(() -> generator.generate(options));
+ assertEquals(
+ plain(at(TIME).size(Size.XS).compactTime(false).randSize(2), 2, 3),
+ assertDoesNotThrow(() -> generator.generate(options)));
+ }
+
+ @Test // (sabotage: cap raised to 11 -> assertThrows saw the refusing draws)
+ void aRandomWidthAboveTenIsRefused() {
+ Monotonic generator = new Monotonic(TestSources.refusingDraws());
+ UxidOptions options = at(TIME).randSize(11).build();
+
+ UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options));
+ assertEquals(ErrorCode.RAND_SIZE_TOO_LARGE, refusal.code());
+ }
+
+ @Test // (sabotage: from skipped when rand is set -> the code assertion failed)
+ void fromIsRefusedBeforeFixedRandomBytes() {
+ Monotonic generator = new Monotonic(TestSources.refusingDraws());
+ UxidOptions options =
+ at(TIME).from("alice@example.com").rand(new byte[] {1, 2}).size(Size.S).build();
+
+ UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options));
+ assertEquals(ErrorCode.FROM_WITH_MONOTONIC, refusal.code());
+ }
+
+ @Test // (sabotage: state cleared on the from refusal -> the R + step id failed)
+ void aRefusedCallLeavesTheGeneratorAsItWas() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(100L, 1L), List.of(5L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).prefix("usr").size(Size.S).build();
+
+ generator.generate(options);
+ assertThrows(
+ UxidException.class,
+ () ->
+ generator.generate(at(TIME).prefix("usr").size(Size.S).from("a@example.com").build()));
+ assertThrows(
+ UxidException.class,
+ () -> generator.generate(at(TIME).prefix("usr").size(Size.S).rand(new byte[2]).build()));
+ assertThrows(
+ UxidException.class,
+ () -> generator.generate(at(TIME).prefix("usr").size(Size.S).randSize(11).build()));
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 105), generator.generate(options));
+ assertEquals(List.of("seed", "step"), draws.calls);
+ }
+
+ @Test // (sabotage: state stored before the guard -> assertDoesNotThrow failed)
+ void anOverflowOntoTheMarkerIsRefusedAndLeavesTheLastTime() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(0xfeL, 7L), List.of(2L, 1L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(LAST_COMPACT).prefix("usr").size(Size.XS).build();
+
+ assertEquals(
+ plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xfe),
+ generator.generate(options));
+ UxidException refusal = assertThrows(UxidException.class, () -> generator.generate(options));
+ assertEquals(ErrorCode.COMPACT_TIME_OUT_OF_RANGE, refusal.code());
+ assertEquals(
+ plain(at(LAST_COMPACT).prefix("usr").size(Size.XS).compactTime(true), 1, 0xff),
+ assertDoesNotThrow(() -> generator.generate(options)));
+ assertEquals(List.of("seed", "step", "seed", "step"), draws.calls);
+ }
+
+ @Test // (sabotage: step range widened -> assertThrows saw nothing thrown)
+ void aDrawOutsideItsRangeIsRefusedAndLeavesTheGeneratorAsItWas() {
+ TestSources.Replay draws = new TestSources.Replay(List.of(100L), List.of(0L, 257L, 4L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(TIME).prefix("usr").size(Size.S).build();
+
+ generator.generate(options);
+ assertThrows(IllegalStateException.class, () -> generator.generate(options));
+ assertThrows(IllegalStateException.class, () -> generator.generate(options));
+ assertEquals(plain(at(TIME).prefix("usr").size(Size.S), 2, 104), generator.generate(options));
+ }
+
+ @Test // (sabotage: a short seed accepted -> assertThrows saw nothing thrown)
+ void aSeedOfTheWrongLengthIsRefused() {
+ UxidOptions options = at(TIME).size(Size.S).build();
+ Monotonic shortSeed = new Monotonic(seeding(new byte[1]));
+ Monotonic nullSeed = new Monotonic(seeding(null));
+ Monotonic shortBytes = new Monotonic();
+ UxidOptions shortSource =
+ UxidOptions.builder().time(TIME).size(Size.S).byteSource(count -> new byte[0]).build();
+
+ assertThrows(IllegalStateException.class, () -> shortSeed.generate(options));
+ assertThrows(IllegalStateException.class, () -> nullSeed.generate(options));
+ assertThrows(IllegalStateException.class, () -> shortBytes.generate(shortSource));
+ }
+
+ @Test // (sabotage: last-millisecond guard removed -> assertThrows saw a seed drawn)
+ void anOverflowInTheLastMillisecondIsRefused() {
+ long last = (1L << 48) - 1;
+ TestSources.Replay draws = new TestSources.Replay(List.of(0xffffL), List.of(1L));
+ Monotonic generator = new Monotonic(draws);
+ UxidOptions options = at(last).size(Size.S).build();
+
+ assertEquals("7zzzzzzzzzzzz1", generator.generate(options));
+ assertThrows(IllegalArgumentException.class, () -> generator.generate(options));
+ }
+
+ @Test // (sabotage: clock read replaced by 0 -> the time range assertion failed)
+ void aCallWithNoTimeReadsTheClock() {
+ Monotonic generator = new Monotonic();
+ long before = System.currentTimeMillis();
+ String first = generator.generate();
+ String second = generator.generate();
+ long after = System.currentTimeMillis();
+
+ assertEquals(26, first.length(), first);
+ assertTrue(first.compareTo(second) < 0, first + " then " + second);
+ DecodeResult.Decoded decoded = (DecodeResult.Decoded) Uxid.decode(first);
+ long time = decoded.time().orElseThrow();
+ assertTrue(time >= before && time <= after, first);
+ }
+
+ @Test // (sabotage: a new lock per call -> a thread's ids did not increase)
+ void oneGeneratorSharedByThreadsGivesStrictlyIncreasingIds() throws Exception {
+ int threads = 8;
+ int perThread = 5_000;
+ Monotonic generator = new Monotonic(constantDraws());
+ UxidOptions options = at(TIME).prefix("usr").size(Size.XL).build();
+ CountDownLatch start = new CountDownLatch(1);
+ ExecutorService pool = Executors.newFixedThreadPool(threads);
+ try {
+ List>> results = new ArrayList<>();
+ for (int t = 0; t < threads; t++) {
+ results.add(
+ pool.submit(
+ () -> {
+ start.await();
+ List ids = new ArrayList<>(perThread);
+ for (int i = 0; i < perThread; i++) {
+ ids.add(generator.generate(options));
+ }
+ return ids;
+ }));
+ }
+ start.countDown();
+ Set all = new HashSet<>();
+ for (Future> result : results) {
+ List ids = result.get(60, TimeUnit.SECONDS);
+ for (int i = 1; i < ids.size(); i++) {
+ assertTrue(ids.get(i - 1).compareTo(ids.get(i)) < 0, ids.get(i - 1) + " " + ids.get(i));
+ }
+ all.addAll(ids);
+ }
+ assertEquals(threads * perThread, all.size());
+ assertTrue(
+ all.contains(plain(at(TIME).prefix("usr").size(Size.XL), 10, threads * perThread - 1)));
+ } finally {
+ pool.shutdownNow();
+ }
+ }
+
+ /** A builder with a fixed time and a byte source that fails the test if it is called. */
+ private static UxidOptions.Builder at(long time) {
+ return UxidOptions.builder().time(time).byteSource(TestSources.refusing());
+ }
+
+ /**
+ * The id {@link Uxid#generate(UxidOptions)} makes for {@code builder} with its {@code width}-byte
+ * random field fixed to {@code value}: what a monotonic call with that time and value answers.
+ */
+ private static String plain(UxidOptions.Builder builder, int width, long value) {
+ byte[] field = new byte[width];
+ long rest = value;
+ for (int i = width - 1; i >= 0; i--) {
+ field[i] = (byte) rest;
+ rest >>>= 8;
+ }
+ return Uxid.generate(builder.rand(field).build());
+ }
+
+ /** A byte source answering each hex string in turn, asked for exactly its length. */
+ private static ByteSource answering(String... hex) {
+ List answers = new ArrayList<>();
+ for (String h : hex) {
+ answers.add(HexFormat.of().parseHex(h));
+ }
+ return count -> {
+ assertTrue(!answers.isEmpty(), "the byte source was asked for more than its answers");
+ byte[] answer = answers.remove(0);
+ assertEquals(answer.length, count, "the count asked for");
+ return answer;
+ };
+ }
+
+ /** A draw source whose every seed is {@code seed} and every step one. */
+ private static DrawSource seeding(byte[] seed) {
+ return new DrawSource() {
+ @Override
+ public byte[] seed(int width) {
+ return seed;
+ }
+
+ @Override
+ public long step(int width) {
+ return 1;
+ }
+ };
+ }
+
+ /** Seeds of zero and steps of one: safe to share, and any repeated id stays visible. */
+ private static DrawSource constantDraws() {
+ return seeding(new byte[10]);
+ }
+}
diff --git a/src/test/java/systems/riddler/uxid/TestSources.java b/src/test/java/systems/riddler/uxid/TestSources.java
index da8065b..a3acb98 100644
--- a/src/test/java/systems/riddler/uxid/TestSources.java
+++ b/src/test/java/systems/riddler/uxid/TestSources.java
@@ -1,6 +1,8 @@
package systems.riddler.uxid;
+import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Deque;
import java.util.List;
/** Byte sources for tests. */
@@ -28,4 +30,66 @@ public byte[] nextBytes(int count) {
return bytes;
}
}
+
+ /** A draw source that fails the test if it is ever called. */
+ static DrawSource refusingDraws() {
+ return new DrawSource() {
+ @Override
+ public byte[] seed(int width) {
+ throw new AssertionError("a seed was drawn for " + width + " bytes");
+ }
+
+ @Override
+ public long step(int width) {
+ throw new AssertionError("a step was drawn for " + width + " bytes");
+ }
+ };
+ }
+
+ /**
+ * A draw source that answers recorded values in order, as the corpus gives them: each seed an
+ * unsigned integer written big-endian in the field's width, each step as it is. It records each
+ * draw as {@code seed} or {@code step} in {@link #calls}.
+ */
+ static final class Replay implements DrawSource {
+ final List calls = new ArrayList<>();
+ private final Deque seeds = new ArrayDeque<>();
+ private final Deque steps = new ArrayDeque<>();
+
+ Replay(List seeds, List steps) {
+ this.seeds.addAll(seeds);
+ this.steps.addAll(steps);
+ }
+
+ @Override
+ public byte[] seed(int width) {
+ calls.add("seed");
+ Long value = seeds.poll();
+ if (value == null) {
+ throw new AssertionError("no seed left to draw");
+ }
+ byte[] bytes = new byte[width];
+ long rest = value;
+ for (int i = width - 1; i >= 0; i--) {
+ bytes[i] = (byte) rest;
+ rest >>>= 8;
+ }
+ return bytes;
+ }
+
+ @Override
+ public long step(int width) {
+ calls.add("step");
+ Long value = steps.poll();
+ if (value == null) {
+ throw new AssertionError("no step left to draw");
+ }
+ return value;
+ }
+
+ /** Whether every recorded seed and step was drawn. */
+ boolean drained() {
+ return seeds.isEmpty() && steps.isEmpty();
+ }
+ }
}