SCUA

How-to

Generate unique ids

You need an id that no other process will ever produce: a row key, a request id, a filename, an idempotency key. The ids module makes them. No capability grant is needed.

import ids

print(ids.uuid7())   -- 01a018fc-7411-7000-be13-4446f9d5c5f4
print(ids.ulid())    -- 01M0CFRX0H0R5SC1K8H5G7TB50
print(ids.uuid4())   -- 5d7441a4-491a-4639-ab01-7d33cabfc6b8

#Which one to use

ids.uuid7() is the one to reach for by default. It puts a millisecond timestamp at the front, so ids sort by creation time as plain text. Sorting a log by its id column sorts it chronologically. A database index on the column fills left to right instead of being written all over.

ids.ulid() is the same idea, shorter. 26 characters instead of 36, no dashes, and its alphabet leaves out I, L, O and U — so an id someone copies off a screen cannot quietly turn into a different valid one. Good when ids appear in logs, URLs or support tickets.

ids.uuid4() is random with no ordering. Use it when you specifically do not want the creation time to be readable from the id, and accept that a database index on it will be scattered.

Ids minted in the same turn still come out in issue order, for both uuid7 and ulid — a loop that mints a hundred thousand of them produces every one after its predecessor.

#They are not secrets

An id is unique. It is not a secret, and the time-ordered ones are not even trying to be: uuid7 and ulid carry a readable creation timestamp by construction, which is the entire point of choosing them.

Do not use an id as a session token, a password-reset link, an API key, or anything else where guessing it is the attack. For those, ask for bits whose only job is to be unguessable:

import crypto
import bytes

let token = bytes.to_hex(crypto.random_bytes(32))   -- 64 hex characters, nothing else in them

crypto.random_bytes(n) is the one place unguessable bits enter the language, and it is also the seed crypto.keypair asks for.

#The timestamp is the turn's clock

The millisecond in a uuid7 or a ulid is the same value now() returns — the turn's stamped clock, frozen for the turn. That has three consequences worth knowing:

  • Under --fast, id timestamps fast-forward with everything else.
  • Under scua test, where the clock starts at zero, ids start at 00000000-0000-…. That is not a bug; it means your test's ids are as reproducible as its clock.
  • Inside one long turn that never waits, every id carries the turn's millisecond. Order is still exact — that comes from a counter, not from the clock — but the timestamps will not spread out until the turn ends.

#They are not replayable

The bits come from the host's random source, not from rand. This is the whole reason the module exists: rand is seeded and deterministic, so rand.seed(42) replays the same stream forever. That is exactly right for lockstep simulation and replay, and exactly wrong for an id — one built on rand would repeat in every process that started from the same seed, and nothing would tell you.

So a re-run produces different ids. If you are recording a session and replaying it, ids generated during the replay will not match the ones recorded.

If you need ids a replay reproduces, use a counter. That is a different requirement and it wants a different tool:

let next_id = 0
fn make_id()
  next_id = next_id + 1
  return `entity-{next_id}`
end

That is reproducible, readable, and fine as long as the ids only need to be unique within one run. Reach for ids when they need to be unique across machines, processes and time.

#Checking an id you were given

ids.parse tells you whether a string is a well-formed id, and hands back exactly the string you gave it.

import ids

match ids.parse(user_supplied)
  Ok(id)     -> store(id)
  Error(why) -> print(`rejected: {why}`)
end

Case is checked, not corrected. A UUID is lowercase and a ULID is uppercase. Hand parse the other spelling and it refuses, rather than quietly returning a different string than you gave it — an id is a value, and a function that rewrites one means you end up storing bytes you never saw.

That matters because the hazard is real: the same UUID arrives lowercase from one system and uppercase from another (.NET and SQL Server emit uppercase), and if both spellings reach your data then joins miss and idempotency keys stop being idempotent, six months later and quietly. The fix is to convert once, at the boundary where the data arrives, in a line you wrote:

let id = str.lower(row.external_id)   -- a UUID from a system that shouts
match ids.parse(id)
  Ok(ok)     -> store(ok)
  Error(why) -> reject(why)
end

One spelling in your database, and you picked it.

It checks the shape only. An id minted by another system is still a perfectly good id, so version and variant bits are not enforced — a v1 UUID from a legacy database parses fine. What it will not do is convert between encodings: a UUID must keep its dashes (a bare 32-character hex run is a different convention, not a different case), and a mistyped O in a ULID is rejected rather than silently read as a 0, because repairing it would hand you a valid id belonging to something else.

#Storing them

An id is a string, so it stores and travels like any other string — in a table, a record field, a frame column, a partition's state, or a JSON payload.

import ids

record Order {
  id:     string,
  total:  decimal,
}

let o: Order = { id = ids.uuid7(), total = 19.99d }

If you are keying a persisted partition or a cluster table, the time-ordered forms are worth preferring for the same reason they help a database: related writes land near each other rather than scattered.