podcast manager
1import {IdentBrand, IdentID} from '#realm/protocol/brands'
2import {z} from 'zod/v4'
3
4export type LCTimestamp = z.infer<typeof LogicalClock.schema>
5export type LCExploded = {
6 identid: IdentID
7 seconds: number
8 counter: number
9}
10
11export type PeerClocks = Record<IdentID, LCTimestamp | null>
12
13export class LogicalClock extends EventTarget {
14 // hlc format: 'lc.seconds.counter.identid'
15
16 static #hlc = Symbol('hlc')
17 static readonly pattern = `lc:\\d+:\\d{6}:${IdentBrand.pattern}`
18 static readonly regexp = new RegExp(`^${this.pattern}`)
19 static readonly schema = z.string().regex(this.regexp).brand(this.#hlc)
20
21 static parse(input: unknown): LCTimestamp {
22 return this.schema.parse(input)
23 }
24
25 static extract(input: LCTimestamp): LCExploded {
26 const [_a, _seconds, _counter, _identid] = input.split(':')
27 const seconds = Number(_seconds)
28 const counter = Number(_counter)
29 const identid = IdentBrand.parse(_identid)
30
31 return {seconds, counter, identid}
32 }
33
34 static validate(input: unknown): input is LCTimestamp {
35 return this.schema.safeParse(input).success
36 }
37
38 static compare(a: LCTimestamp, b: LCTimestamp): number {
39 const [_a, asec, acounter, aident] = a.split(':')
40 const [_b, bsec, bcounter, bident] = b.split(':')
41
42 const secdiff = Number(asec) - Number(bsec)
43 if (secdiff !== 0) return secdiff
44
45 const counterdiff = Number(acounter) - Number(bcounter)
46 if (counterdiff !== 0) return counterdiff
47
48 return aident.localeCompare(bident)
49 }
50
51 static elapsed(timestamp: LCTimestamp, identid: IdentID): number {
52 const [_lc, seconds, _counter, tsIdentid] = timestamp.split(':')
53
54 // only meaningful for our own timestamps
55 if (tsIdentid !== identid) return NaN
56
57 const timestampMs = Number(seconds) * 1000
58 return Date.now() - timestampMs
59 }
60
61 static distance(from: LCTimestamp, to: LCTimestamp): number {
62 const [_a, asec, acounter, aident] = from.split(':')
63 const [_b, bsec, bcounter, bident] = to.split(':')
64
65 // only meaningful within same node
66 if (aident !== bident) return NaN
67
68 const secDiff = Number(bsec) - Number(asec)
69 const counterDiff = Number(bcounter) - Number(acounter)
70
71 // Logical distance = seconds diff + counter diff
72 return secDiff + counterDiff / 1000
73 }
74
75 ///
76
77 #identid: IdentID
78 #counter = 0
79 #seconds = 0
80 #latest: LCTimestamp | null = null
81
82 constructor(identid: IdentID, latest: LCTimestamp | null = null) {
83 super()
84 this.#identid = identid
85 this.#latest = latest
86 }
87
88 get actor() {
89 return this.#identid
90 }
91
92 latest(): LCTimestamp | null {
93 return this.#latest
94 }
95
96 now(): LCTimestamp {
97 const seconds = Math.round(Date.now() / 1000)
98
99 if (seconds === this.#seconds) {
100 // same second, bump the counter
101 this.#counter++
102 } else if (seconds < this.#seconds) {
103 // clock went backwards, keep counter incrementing
104 this.#counter++
105 } else {
106 // future second, restart the counter
107 this.#counter = 0
108 this.#seconds = seconds
109 }
110
111 const counter = String(this.#counter).padStart(6, '0')
112 const hlcstr = `lc:${this.#seconds.toFixed(0)}:${counter}:${this.#identid}`
113 return this.tick(LogicalClock.parse(hlcstr))
114 }
115
116 tick(timestamp: LCTimestamp) {
117 if (!this.#latest || LogicalClock.compare(this.#latest, timestamp) < 0) {
118 this.#latest = timestamp
119 this.dispatchEvent(new CustomEvent('tick', {detail: timestamp}))
120 }
121
122 return this.#latest
123 }
124
125 elapsed(timestamp: LCTimestamp): number {
126 return LogicalClock.elapsed(timestamp, this.#identid)
127 }
128
129 distance(from: LCTimestamp, to: LCTimestamp): number {
130 return LogicalClock.distance(from, to)
131 }
132}