podcast manager
at main 132 lines 3.7 kB view raw
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}