@hanzo/event 0.3.6 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/storage.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // client imports cleanly in a Next.js server component.
4
4
 
5
5
  import type { Attribution, Cohort } from './types'
6
+ import { uuidv7 } from './uid'
6
7
 
7
8
  const KEY = {
8
9
  anon: 'hz_anon_id',
@@ -23,19 +24,13 @@ function ls(): Storage | undefined {
23
24
  }
24
25
  }
25
26
 
26
- function uid(): string {
27
- const c = typeof crypto !== 'undefined' ? crypto : undefined
28
- if (c && 'randomUUID' in c) return c.randomUUID()
29
- return 'a-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
30
- }
31
-
32
27
  /** Stable anonymous id, minted once per browser and reused across sessions. */
33
28
  export function anonId(): string | undefined {
34
29
  const s = ls()
35
30
  if (!s) return undefined
36
31
  let v = s.getItem(KEY.anon)
37
32
  if (!v) {
38
- v = uid()
33
+ v = uuidv7()
39
34
  s.setItem(KEY.anon, v)
40
35
  }
41
36
  return v
@@ -46,7 +41,14 @@ interface SessionState {
46
41
  last: number
47
42
  }
48
43
 
49
- /** Current session id, rotated after SESSION_TTL_MS of inactivity. */
44
+ /**
45
+ * Current session id, rotated after SESSION_TTL_MS of inactivity.
46
+ *
47
+ * Minted at `now`, so the v7 timestamp the id carries IS the session's start
48
+ * instant — which is what the session rollups partition and order on. Passing the
49
+ * caller's clock rather than reading Date.now() again keeps the id's embedded time
50
+ * and the recorded `last` from disagreeing.
51
+ */
50
52
  export function sessionId(now = Date.now()): string | undefined {
51
53
  const s = ls()
52
54
  if (!s) return undefined
@@ -57,7 +59,7 @@ export function sessionId(now = Date.now()): string | undefined {
57
59
  state = null
58
60
  }
59
61
  if (!state || now - state.last > SESSION_TTL_MS) {
60
- state = { id: uid(), last: now }
62
+ state = { id: uuidv7(now), last: now }
61
63
  } else {
62
64
  state.last = now
63
65
  }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it, afterEach } from 'vitest'
2
+ import { uuidv7, uuidv7Time } from './uid'
3
+ import { eventId } from './sentry'
4
+
5
+ /** The event plane's admission gate, transcribed from the session rollup's own SQL:
6
+ * `bitAnd(bitShiftRight(toUInt128(accurateCastOrNull(id,'UUID')), 76), 15) = 7`.
7
+ * An id that fails this is dropped by the materialized view without an error. */
8
+ const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
9
+
10
+ /** The session start the rollup partitions and orders on:
11
+ * `fromUnixTimestamp(intDiv(toUInt64(bitShiftRight(session_id_v7, 80)), 1000))`. */
12
+ const embeddedMs = (id: string): bigint => BigInt('0x' + id.replace(/-/g, '')) >> 80n
13
+
14
+ /** RFC 9562 variant: the two high bits of octet 8 are 0b10. */
15
+ const variantBits = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 62n) & 3n
16
+
17
+ const SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
18
+
19
+ describe('uuidv7', () => {
20
+ const realCrypto = globalThis.crypto
21
+ afterEach(() => {
22
+ Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true })
23
+ })
24
+
25
+ it('passes the plane admission gate that v4 fails', () => {
26
+ for (let i = 0; i < 200; i++) expect(versionNibble(uuidv7())).toBe(7n)
27
+ // The gate is real: crypto.randomUUID (v4) is what the SDK used to mint.
28
+ expect(versionNibble(crypto.randomUUID())).toBe(4n)
29
+ })
30
+
31
+ it('carries the mint instant the rollup keys on', () => {
32
+ for (const ms of [0, 1, 1_000, Date.now(), 253_402_300_799_000]) {
33
+ const id = uuidv7(ms)
34
+ expect(embeddedMs(id)).toBe(BigInt(ms))
35
+ expect(uuidv7Time(id)).toBe(ms)
36
+ }
37
+ })
38
+
39
+ it('sets the RFC 9562 variant', () => {
40
+ for (let i = 0; i < 50; i++) expect(variantBits(uuidv7())).toBe(2n)
41
+ })
42
+
43
+ it('sorts lexically by time', () => {
44
+ const t = Date.now()
45
+ const ids = [t + 3000, t, t + 1000, t + 2000].map((ms) => uuidv7(ms))
46
+ expect([...ids].sort()).toEqual([ids[1], ids[2], ids[3], ids[0]])
47
+ })
48
+
49
+ it('is unique within a single millisecond', () => {
50
+ const ms = Date.now()
51
+ const seen = new Set(Array.from({ length: 5000 }, () => uuidv7(ms)))
52
+ expect(seen.size).toBe(5000)
53
+ })
54
+
55
+ it('keeps a valid v7 shape with no crypto at all', () => {
56
+ Object.defineProperty(globalThis, 'crypto', { value: undefined, configurable: true })
57
+ const id = uuidv7(1_700_000_000_000)
58
+ // The old fallback emitted 'a-<base36>', which the plane casts to NULL and drops
59
+ // by the same gate v4 fails. Only entropy may degrade — never the shape.
60
+ expect(id).toMatch(SHAPE)
61
+ expect(versionNibble(id)).toBe(7n)
62
+ expect(embeddedMs(id)).toBe(1_700_000_000_000n)
63
+ })
64
+ })
65
+
66
+ describe('eventId', () => {
67
+ it('is the one minter formatted for the Sentry wire', () => {
68
+ const id = eventId()
69
+ expect(id).toMatch(/^[0-9a-f]{32}$/)
70
+ const dashed = `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`
71
+ expect(versionNibble(dashed)).toBe(7n)
72
+ })
73
+ })
package/src/uid.ts ADDED
@@ -0,0 +1,70 @@
1
+ // The ONE id minter for this client — UUIDv7 (RFC 9562 §5.7).
2
+ //
3
+ // WHY NOT crypto.randomUUID(): it mints v4, whose 122 bits are pure entropy and
4
+ // carry no time. The session rollups on the event plane derive a session's start
5
+ // instant FROM THE ID — their PARTITION BY and ORDER BY are
6
+ // `fromUnixTimestamp(intDiv(toUInt64(bitShiftRight(session_id_v7, 80)), 1000))` —
7
+ // so they admit only ids whose version nibble is 7
8
+ // (`bitAnd(bitShiftRight(toUInt128(session_id), 76), 15) = 7`). A v4 session id is
9
+ // not merely unordered there: it is DISCARDED at the door, silently, and the
10
+ // rollup stays empty forever. Minting v7 is the whole reason those rollups can
11
+ // exist; it also clusters index writes by time instead of scattering them across
12
+ // the keyspace.
13
+ //
14
+ // Layout, 16 bytes big-endian:
15
+ // 0..5 unix_ts_ms — 48-bit millisecond timestamp
16
+ // 6 0111 (version 7) in the high nibble | rand_a
17
+ // 7 rand_a
18
+ // 8 10 (variant) in the high 2 bits | rand_b
19
+ // 9..15 rand_b
20
+ //
21
+ // Never returns a non-UUID shape. The old minters fell back to `'a-' + base36`
22
+ // when crypto was absent, and the plane casts a session id with
23
+ // accurateCastOrNull(…, 'UUID') — a shape that does not parse becomes NULL and is
24
+ // dropped by the same gate, so the fallback failed exactly like v4 did. Here only
25
+ // the ENTROPY degrades without crypto; the shape is always a valid v7 UUID.
26
+
27
+ /** 00..ff, so formatting is a lookup rather than 16 padStart calls. */
28
+ const HEX: string[] = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).slice(1))
29
+
30
+ /** Cryptographic randomness when the host has it, Math.random when it does not. */
31
+ function fill(b: Uint8Array): Uint8Array {
32
+ const c = typeof crypto !== 'undefined' ? crypto : undefined
33
+ if (c && typeof c.getRandomValues === 'function') {
34
+ c.getRandomValues(b)
35
+ return b
36
+ }
37
+ for (let i = 0; i < b.length; i++) b[i] = (Math.random() * 256) | 0
38
+ return b
39
+ }
40
+
41
+ /**
42
+ * uuidv7 mints a time-ordered UUIDv7 for `now` (epoch milliseconds).
43
+ *
44
+ * Two ids minted in the same millisecond sort arbitrarily between themselves; ids
45
+ * from different milliseconds sort by time, lexically and numerically alike.
46
+ */
47
+ export function uuidv7(now: number = Date.now()): string {
48
+ const b = fill(new Uint8Array(16))
49
+ // 48-bit big-endian timestamp. Milliseconds stay exact well past year 10000, so
50
+ // the arithmetic never leaves the safe-integer range.
51
+ let t = Math.floor(now)
52
+ for (let i = 5; i >= 0; i--) {
53
+ b[i] = t % 256
54
+ t = Math.floor(t / 256)
55
+ }
56
+ b[6] = 0x70 | (b[6] & 0x0f) // version 7
57
+ b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
58
+ return (
59
+ HEX[b[0]] + HEX[b[1]] + HEX[b[2]] + HEX[b[3]] + '-' +
60
+ HEX[b[4]] + HEX[b[5]] + '-' +
61
+ HEX[b[6]] + HEX[b[7]] + '-' +
62
+ HEX[b[8]] + HEX[b[9]] + '-' +
63
+ HEX[b[10]] + HEX[b[11]] + HEX[b[12]] + HEX[b[13]] + HEX[b[14]] + HEX[b[15]]
64
+ )
65
+ }
66
+
67
+ /** The millisecond timestamp a v7 id was minted at — the inverse of uuidv7. */
68
+ export function uuidv7Time(id: string): number {
69
+ return parseInt(id.slice(0, 8) + id.slice(9, 13), 16)
70
+ }
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // The library version, stamped on every event (`libraryVersion`) and on the
2
2
  // Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
3
3
  // `core.ts` — core imports sentry, so the reverse would be an import cycle.
4
- export const VERSION = '0.3.6'
4
+ export const VERSION = '0.3.9'