Skip to content

Commit 4a960c7

Browse files
committed
Add createIdAsUuidv7 and simplify Id docs
1 parent 6cbaad3 commit 4a960c7

2 files changed

Lines changed: 78 additions & 45 deletions

File tree

.changeset/polite-paws-taste.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@evolu/common": patch
3+
---
4+
5+
Add optional `createIdAsUuidv7` helper for timestamp‑embedded IDs (UUID v7 layout) while keeping `createId` as the privacy‑preserving default.
6+
7+
Simplified Id documentation to clearly present the three creation paths:
8+
9+
- `createId` (random, recommended)
10+
- `createIdFromString` (deterministic mapping via SHA‑256 first 16 bytes)
11+
- `createIdAsUuidv7` (timestamp bits for index locality; leaks creation time)

packages/common/src/Type.ts

Lines changed: 67 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import { isPlainObject } from "./Object.js";
212212
import { hasNodeBuffer } from "./Platform.js";
213213
import { err, getOrNull, getOrThrow, ok, Result, trySync } from "./Result.js";
214214
import { safelyStringifyUnknownValue } from "./String.js";
215+
import type { TimeDep } from "./Time.js";
215216
import type { Literal, Simplify, WidenLiteral } from "./Types.js";
216217
import { IntentionalNever } from "./Types.js";
217218

@@ -1589,41 +1590,27 @@ export const formatSimplePasswordError = (
15891590
);
15901591

15911592
/**
1592-
* Globally unique identifier.
1593-
*
1594-
* **Evolu Id** is 16 random bytes from a cryptographically secure random
1595-
* generator, encoded as 22-character Base64Url string. This provides strong
1596-
* collision resistance for distributed ID generation.
1597-
*
1598-
* ### Design Rationale
1599-
*
1600-
* Why Evolu Id over alternatives:
1601-
*
1602-
* - **NanoID**: No standard binary serialization format, and uses only ~126 bits
1603-
* of entropy (21 characters from 64-symbol alphabet) compared to Evolu Id's
1604-
* 128 bits.
1605-
* - **UUID (v4)**: String format is 36 characters (with hyphens) compared to
1606-
* Evolu Id's 22 characters. While UUIDs can be stored as 16 bytes, their
1607-
* standard string representation is verbose.
1608-
* - **UUID v7**: Includes timestamp in the ID, which leaks information about when
1609-
* data was created. This is a privacy concern for local-first applications
1610-
* where creation time must remain private.
1611-
*
1612-
* Evolu Id provides 128 bits of entropy, compact string representation (22
1613-
* characters), standard and native string serialization (Base64Url), and no
1614-
* privacy leaks.
1615-
*
1616-
* ### Future Consideration
1617-
*
1618-
* For database-heavy workloads where insert performance is critical, a hybrid
1619-
* approach could be considered: `timestamp ^ H(cluster_id, timestamp >> N)`
1620-
* where H is a keyed hash function and N is a configurable parameter. This
1621-
* would maintain spatial locality for database caches (improving insert
1622-
* performance by an order of magnitude) while adding entropy to prevent
1623-
* timestamp leakage and correlation across systems. The parameter N would allow
1624-
* trading off cache locality (larger N = better locality) versus entropy
1625-
* distribution. See https://brooker.co.za/blog/2025/10/22/uuidv7.html for
1626-
* details on this approach.
1593+
* Evolu Id: 16 bytes encoded as a 22‑character Base64Url string.
1594+
*
1595+
* There are three ways to create an Evolu Id:
1596+
*
1597+
* - {@link createId} – default cryptographically secure random bytes
1598+
* (privacy‑preserving)
1599+
* - {@link createIdFromString} – deterministic: first 16 bytes of SHA‑256 of a
1600+
* string
1601+
* - {@link createIdAsUuidv7} – optional: embeds timestamp bits (UUID v7 layout)
1602+
*
1603+
* Privacy: the default random Id does not leak creation time and is safe to
1604+
* share or log. The UUID v7 variant leaks creation time anywhere the Id is
1605+
* copied (logs, URLs, exports); only use it when you explicitly want insertion
1606+
* locality for very large write‑heavy tables and accept timestamp exposure.
1607+
*
1608+
* ### Future
1609+
*
1610+
* A possible hybrid masked‑time approach (`timestamp ^ H(cluster_id, timestamp
1611+
*
1612+
* > > N)`) could provide locality without exposing raw creation time. See
1613+
* > > https://brooker.co.za/blog/2025/10/22/uuidv7.html
16271614
*
16281615
* @category String
16291616
*/
@@ -1641,26 +1628,25 @@ export const formatIdError = createTypeErrorFormatter<IdError>(
16411628
);
16421629

16431630
/**
1644-
* Creates an {@link Id}.
1631+
* Creates a random {@link Id}. This is the recommended default.
1632+
*
1633+
* Use {@link createIdFromString} for deterministic mapping of external IDs or
1634+
* {@link createIdAsUuidv7} when you accept timestamp leakage for index
1635+
* locality.
16451636
*
16461637
* ### Example
16471638
*
16481639
* ```ts
1649-
* // string & Brand<"Id">
16501640
* const id = createId(deps);
1651-
*
1652-
* // string & Brand<"Id"> & Brand<"Todo">
16531641
* const todoId = createId<"Todo">(deps);
16541642
* ```
16551643
*/
16561644
export const createId = <B extends string = never>(
16571645
deps: RandomBytesDep,
1658-
): [B] extends [never] ? Id : Id & Brand<B> =>
1659-
uint8ArrayToBase64Url(deps.randomBytes.create(16)) as unknown as [B] extends [
1660-
never,
1661-
]
1662-
? Id
1663-
: Id & Brand<B>;
1646+
): [B] extends [never] ? Id : Id & Brand<B> => {
1647+
const id = uint8ArrayToBase64Url(deps.randomBytes.create(16));
1648+
return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
1649+
};
16641650

16651651
/**
16661652
* Creates an {@link Id} from a string using SHA-256.
@@ -1704,6 +1690,42 @@ export const createIdFromString = <B extends string = never>(
17041690
return id as [B] extends [never] ? Id : Id & Brand<B>;
17051691
};
17061692

1693+
/**
1694+
* Creates an {@link Id} embedding timestamp bits (UUID v7 layout) before
1695+
* Base64Url encoding.
1696+
*
1697+
* Tradeoff: better insertion locality / index performance for huge datasets vs
1698+
* leaking creation time everywhere the Id appears. Evolu uses {@link createId}
1699+
* by default to avoid activity leakage; choose this only if you explicitly
1700+
* accept timestamp exposure.
1701+
*
1702+
* ### Example
1703+
*
1704+
* ```ts
1705+
* const id = createIdAsUuidv7({ randomBytes, time });
1706+
* const todoId = createIdAsUuidv7<"Todo">({ randomBytes, time });
1707+
* ```
1708+
*/
1709+
export const createIdAsUuidv7 = <B extends string = never>(
1710+
deps: RandomBytesDep & TimeDep,
1711+
): [B] extends [never] ? Id : Id & Brand<B> => {
1712+
const id = deps.randomBytes.create(16);
1713+
1714+
const timestamp = globalThis.BigInt(deps.time.now());
1715+
1716+
id[0] = globalThis.Number((timestamp >> 40n) & 0xffn);
1717+
id[1] = globalThis.Number((timestamp >> 32n) & 0xffn);
1718+
id[2] = globalThis.Number((timestamp >> 24n) & 0xffn);
1719+
id[3] = globalThis.Number((timestamp >> 16n) & 0xffn);
1720+
id[4] = globalThis.Number((timestamp >> 8n) & 0xffn);
1721+
id[5] = globalThis.Number(timestamp & 0xffn);
1722+
1723+
id[6] = (id[6] & 0x0f) | 0x70;
1724+
id[8] = (id[8] & 0x3f) | 0x80;
1725+
1726+
return id as unknown as [B] extends [never] ? Id : Id & Brand<B>;
1727+
};
1728+
17071729
/**
17081730
* Creates a branded {@link Id} Type for a table's primary key.
17091731
*

0 commit comments

Comments
 (0)