Articles
ashid for CFML: time-sortable, prefixed IDs without a JAR
I've been quietly tired of createUUID() for a while. It's fine for primary keys nobody ever looks at, but the moment an ID surfaces in a URL or a log line, opacity becomes a tax. You can't tell at a glance whether 7F3A1B2C-... belongs to a user account or a webhook event. You can't lexicographically sort by creation time. And the dashes mean you can't double-click-select it cleanly.
SQL Server has had newsequentialid() for years, which does the time-sortable part at the database layer. But that's a column default that emits a binary GUID. Fine for INSERTs, no help when you need an identifier in CFML before any row exists. And there's no native CFML equivalent.
So I ported ashid to CFML. ashid produces IDs that look like this:
ashid("user") // user_1kbg1jmtt4v3x8k9p2m1np0
ashid("event") // event_1kbg1jmtt4q9z2p7m3rk0w
ashid() // 01kbg1jmtt4v3x8k9p2m1n0w
ashid4("token") // token_<13 random><13 random>
The format is inspired by Stripe's IDs, ULID, and TypeID. Crockford Base32 keeps the alphabet to 32 lowercase characters with no I/L/O/U ambiguity. Self-documenting prefix, embedded millisecond timestamp, lexicographically sortable, double-click-selectable as a single token, case-insensitive on decode.
The port
box install ashid and you've got it. Pure CFML, no third-party JARs at runtime. It leans only on java.security.SecureRandom and java.math.BigInteger from the standard library. I tested on Adobe ColdFusion 2016+, Lucee 5+, and BoxLang 1+.
The IDs are byte-for-byte interchangeable with the upstream Kotlin v1.0.3 source. There's a frozen test suite of known vectors in tests/specs/KnownVectorsTest.cfc that locks the algorithm output across all three engines.
One honesty caveat up front: Maven Central currently publishes only agency.wilde:ashid:1.0.0, which predates the ashid4 API and the auto-underscore prefix normalization. The CFML port targets v1.0.3 from the GitHub main branch. So if you compare CFML output against the published JAR, prefixed IDs won't match (user_... vs. user...). For unprefixed IDs they agree. Build a v1.0.3 JAR yourself if you need bytewise parity with Java callers.
The bit that surprised me: 64-bit math through CFML
CFML numbers are IEEE-754 doubles. That gets you 53 bits of integer precision before silent rounding kicks in. ashid's random component is a signed 63-bit Long, and ashid4 uses an unsigned 64-bit value. Either of those overflows a CFML number with no warning, and you just get a different ID than the algorithm specified.
The fix is to keep the random value as a java.math.BigInteger from the moment SecureRandom produces the bytes through to the final encode. Eight bytes get assembled into the BigInteger one at a time:
v = _bigAdd(_bigMul(v, BIG_256), BigInt.valueOf(javaCast("long", unsignedByte)));
That's where the second surprise hit. On a fresh BigInteger.ZERO accumulator, BoxLang 1.x routes the bare bigInt.add(...) call through its CFML Number BIF instead of the Java instance method. You get Required argument number is missing for function add. The receiver being numerically zero confuses BoxLang's BIF dispatcher into thinking it should call Number.add().
The portable workaround is reflection. The encoder caches java.lang.reflect.Method handles for BigInteger.add and BigInteger.multiply at init, then invokes them explicitly:
var BigIntClass = variables.BigInt.getClass();
variables._addMethod = BigIntClass.getMethod("add", [BigIntClass]);
variables._mulMethod = BigIntClass.getMethod("multiply", [BigIntClass]);
Reflection bypasses the BIF dispatcher and forces the Java method call on every engine. Costs about 25-30% throughput vs. direct member calls, but the same code now runs identically on ACF, Lucee, and BoxLang.
ACF 2016 had its own quirk worth a sentence: its switch statement is case-insensitive, which would silently collapse o/O and i/I decode entries into each other. The decoder uses a struct lookup keyed on the actual character so the case-insensitive Crockford lookalike map (O -> 0, I -> 1, L -> 1, U -> V) lives where I can read it.
Performance
benchmark/run.cfm runs 50,000 iterations after a 1,000-iteration warmup. Numbers from a Windows 11 dev box:
| Engine | Op | CFML ops/sec | JAR ops/sec |
|---|---|---|---|
| Lucee 5 | generate("user") |
~10,058 | ~234,741 |
| ACF 2016 | generate("user") |
~6,626 | n/a |
| BoxLang 1 | generate("user") |
~5,171 | ~152,905 |
| Lucee 5 | parse(id) |
~181,818 | n/a |
The JAR is 23-67x faster than the CFML port. That's expected. Every call allocates fresh BigInteger objects and concatenates strings, plus the reflection workaround for BoxLang adds another ~25-30% on top. None of which matters in practice. 5,000-10,000 prefixed IDs per second on the slowest engine is three orders of magnitude faster than any realistic request rate. If your app needs more than that, you have other problems.
Quick start
// In Application.cfc onApplicationStart:
application.ashid = new ashid.Ashid();
include "/ashid/helpers.cfm";
// Anywhere downstream:
var id = ashid("user"); // user_1kbg1jmtt4v3x8k9p2m1np0
var parts = parseAshid(id); // ["user_", "1kbg1jmtt", "4v3x8k9p2m1np0"]
var ts = application.ashid.timestamp(id); // 1778025600000
The singleton is thread-safe. SecureRandom documents thread safety on the JVM, and the encoder holds no mutable state after init.
One last quirk to flag, because it'll bite somebody otherwise. The upstream parse() walker uses isLetter() to find the prefix boundary, so prefixes containing digits won't survive a round-trip. ashid("u1") produces an ID, but parse() bails on the digit and isValid() returns false. Stick to letter-only prefixes like user, event, token. I kept the upstream behavior rather than patching it locally so output stays bytewise compatible.
Links
- Repo: https://github.com/jamoCA/cf-ashid
- ForgeBox:
box install ashid - Upstream Kotlin/TypeScript: https://github.com/wildeagency/ashid
Issues and PRs welcome. The test suite covers all three engines, so if you find a CFML edge case I missed, the failure should reproduce locally without much fuss.