offline-first, p2p synced, atproto enabled, feed reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add specs for clocks

+715 -5
+414
src/realm/logical-clock.spec.ts
··· 1 + import {IdentBrand} from '#realm/protocol/brands' 2 + import {compare, explode, generate} from '#realm/protocol/timestamp' 3 + import {beforeEach, describe, expect, it, jest} from '@jest/globals' 4 + import {LogicalClock} from './logical-clock' 5 + 6 + describe('LogicalClock', () => { 7 + let identAlice: ReturnType<typeof IdentBrand.generate> 8 + let identBob: ReturnType<typeof IdentBrand.generate> 9 + 10 + beforeEach(() => { 11 + identAlice = IdentBrand.generate() 12 + identBob = IdentBrand.generate() 13 + }) 14 + 15 + describe('constructor', () => { 16 + it('creates a clock with no initial timestamp', () => { 17 + const clock = new LogicalClock(identAlice) 18 + 19 + expect(clock.actor).toBe(identAlice) 20 + expect(clock.latest()).toBeNull() 21 + }) 22 + 23 + it('creates a clock with an initial timestamp', () => { 24 + const initialTimestamp = generate(identAlice, 1000, 0) 25 + const clock = new LogicalClock(identAlice, initialTimestamp) 26 + 27 + expect(clock.actor).toBe(identAlice) 28 + expect(clock.latest()).toBe(initialTimestamp) 29 + }) 30 + }) 31 + 32 + describe('actor', () => { 33 + it('returns the identity of the clock owner', () => { 34 + const clock = new LogicalClock(identAlice) 35 + 36 + expect(clock.actor).toBe(identAlice) 37 + }) 38 + }) 39 + 40 + describe('latest', () => { 41 + it('returns null initially when no timestamp is set', () => { 42 + const clock = new LogicalClock(identAlice) 43 + 44 + expect(clock.latest()).toBeNull() 45 + }) 46 + 47 + it('returns the latest timestamp after generating one', () => { 48 + const clock = new LogicalClock(identAlice) 49 + const timestamp = clock.now() 50 + 51 + expect(clock.latest()).toBe(timestamp) 52 + }) 53 + 54 + it('returns the bootstrapped timestamp', () => { 55 + const initialTimestamp = generate(identAlice, 1000, 0) 56 + const clock = new LogicalClock(identAlice, initialTimestamp) 57 + 58 + expect(clock.latest()).toBe(initialTimestamp) 59 + }) 60 + }) 61 + 62 + describe('now', () => { 63 + it('generates a monotonically increasing timestamp', () => { 64 + const clock = new LogicalClock(identAlice) 65 + 66 + const ts1 = clock.now() 67 + const ts2 = clock.now() 68 + const ts3 = clock.now() 69 + 70 + expect(compare(ts1, ts2)).toBeLessThan(0) 71 + expect(compare(ts2, ts3)).toBeLessThan(0) 72 + }) 73 + 74 + it('increments counter for timestamps in same second', () => { 75 + const clock = new LogicalClock(identAlice) 76 + 77 + const ts1 = clock.now() 78 + const ts2 = clock.now() 79 + 80 + const {seconds: s1, counter: c1} = explode(ts1) 81 + const {seconds: s2, counter: c2} = explode(ts2) 82 + 83 + if (s1 === s2) { 84 + expect(c2).toBe(c1 + 1) 85 + } 86 + }) 87 + 88 + it('uses the clock actor identity', () => { 89 + const clock = new LogicalClock(identAlice) 90 + const timestamp = clock.now() 91 + 92 + const {identid} = explode(timestamp) 93 + expect(identid).toBe(identAlice) 94 + }) 95 + 96 + it('updates latest timestamp', () => { 97 + const clock = new LogicalClock(identAlice) 98 + 99 + expect(clock.latest()).toBeNull() 100 + 101 + const ts1 = clock.now() 102 + expect(clock.latest()).toBe(ts1) 103 + 104 + const ts2 = clock.now() 105 + expect(clock.latest()).toBe(ts2) 106 + }) 107 + 108 + it('handles rapid successive calls', () => { 109 + const clock = new LogicalClock(identAlice) 110 + const timestamps = [] 111 + 112 + for (let i = 0; i < 100; i++) { 113 + timestamps.push(clock.now()) 114 + } 115 + 116 + // All timestamps should be strictly ordered 117 + for (let i = 0; i < timestamps.length - 1; i++) { 118 + expect(compare(timestamps[i], timestamps[i + 1])).toBeLessThan(0) 119 + } 120 + }) 121 + 122 + it('generates timestamps with current physical time', () => { 123 + const clock = new LogicalClock(identAlice) 124 + const beforeSeconds = Math.round(Date.now() / 1000) 125 + 126 + const timestamp = clock.now() 127 + 128 + const afterSeconds = Math.round(Date.now() / 1000) 129 + const {seconds} = explode(timestamp) 130 + 131 + // Timestamp seconds should be within the test execution window 132 + expect(seconds).toBeGreaterThanOrEqual(beforeSeconds) 133 + expect(seconds).toBeLessThanOrEqual(afterSeconds) 134 + }) 135 + }) 136 + 137 + describe('tick', () => { 138 + it('advances clock with newer timestamp', () => { 139 + const clock = new LogicalClock(identAlice) 140 + const ts1 = generate(identAlice, 1000, 0) 141 + 142 + const result = clock.tick(ts1) 143 + 144 + expect(result).toBe(ts1) 145 + expect(clock.latest()).toBe(ts1) 146 + }) 147 + 148 + it('does not regress clock with older timestamp', () => { 149 + const clock = new LogicalClock(identAlice) 150 + const ts1 = generate(identAlice, 2000, 0) 151 + const ts2 = generate(identAlice, 1000, 0) 152 + 153 + clock.tick(ts1) 154 + const result = clock.tick(ts2) 155 + 156 + expect(result).toBe(ts1) 157 + expect(clock.latest()).toBe(ts1) 158 + }) 159 + 160 + it('accepts timestamp from different actor', () => { 161 + const clock = new LogicalClock(identAlice) 162 + const bobTimestamp = generate(identBob, 1000, 0) 163 + 164 + const result = clock.tick(bobTimestamp) 165 + 166 + expect(result).toBe(bobTimestamp) 167 + expect(clock.latest()).toBe(bobTimestamp) 168 + }) 169 + 170 + it('maintains monotonicity when mixing now() and tick()', () => { 171 + const clock = new LogicalClock(identAlice) 172 + 173 + const ts1 = clock.now() 174 + const externalTs = generate(identBob, 9999999999, 0) // Far future 175 + clock.tick(externalTs) 176 + const ts2 = clock.now() 177 + 178 + expect(compare(ts1, externalTs)).toBeLessThan(0) 179 + expect(compare(externalTs, ts2)).toBeLessThanOrEqual(0) 180 + }) 181 + 182 + it('returns latest timestamp even when not advancing', () => { 183 + const clock = new LogicalClock(identAlice) 184 + const ts1 = generate(identAlice, 2000, 0) 185 + const ts2 = generate(identAlice, 1000, 0) 186 + 187 + clock.tick(ts1) 188 + const result = clock.tick(ts2) 189 + 190 + expect(result).toBe(ts1) 191 + }) 192 + 193 + it('handles equal timestamps idempotently', () => { 194 + const clock = new LogicalClock(identAlice) 195 + const timestamp = generate(identAlice, 1000, 0) 196 + 197 + const result1 = clock.tick(timestamp) 198 + const result2 = clock.tick(timestamp) 199 + 200 + expect(result1).toBe(timestamp) 201 + expect(result2).toBe(timestamp) 202 + expect(clock.latest()).toBe(timestamp) 203 + }) 204 + }) 205 + 206 + describe('tick event', () => { 207 + it('dispatches tick event when clock advances', () => { 208 + const clock = new LogicalClock(identAlice) 209 + const listener = jest.fn() 210 + 211 + clock.addEventListener('tick', listener) 212 + 213 + const timestamp = generate(identAlice, 1000, 0) 214 + clock.tick(timestamp) 215 + 216 + expect(listener).toHaveBeenCalledTimes(1) 217 + expect(listener).toHaveBeenCalledWith( 218 + expect.objectContaining({ 219 + type: 'tick', 220 + detail: timestamp, 221 + }), 222 + ) 223 + }) 224 + 225 + it('does not dispatch event when clock does not advance', () => { 226 + const clock = new LogicalClock(identAlice) 227 + const listener = jest.fn() 228 + 229 + const ts1 = generate(identAlice, 2000, 0) 230 + const ts2 = generate(identAlice, 1000, 0) 231 + 232 + clock.tick(ts1) 233 + clock.addEventListener('tick', listener) 234 + clock.tick(ts2) // Older, should not trigger event 235 + 236 + expect(listener).not.toHaveBeenCalled() 237 + }) 238 + 239 + it('dispatches event on each now() call', () => { 240 + const clock = new LogicalClock(identAlice) 241 + const listener = jest.fn() 242 + 243 + clock.addEventListener('tick', listener) 244 + 245 + clock.now() 246 + clock.now() 247 + clock.now() 248 + 249 + expect(listener).toHaveBeenCalledTimes(3) 250 + }) 251 + 252 + it('allows multiple listeners', () => { 253 + const clock = new LogicalClock(identAlice) 254 + const listener1 = jest.fn() 255 + const listener2 = jest.fn() 256 + 257 + clock.addEventListener('tick', listener1) 258 + clock.addEventListener('tick', listener2) 259 + 260 + const timestamp = generate(identAlice, 1000, 0) 261 + clock.tick(timestamp) 262 + 263 + expect(listener1).toHaveBeenCalledTimes(1) 264 + expect(listener2).toHaveBeenCalledTimes(1) 265 + }) 266 + 267 + it('can remove listeners', () => { 268 + const clock = new LogicalClock(identAlice) 269 + const listener = jest.fn() 270 + 271 + clock.addEventListener('tick', listener) 272 + clock.tick(generate(identAlice, 1000, 0)) 273 + 274 + expect(listener).toHaveBeenCalledTimes(1) 275 + 276 + clock.removeEventListener('tick', listener) 277 + clock.tick(generate(identAlice, 2000, 0)) 278 + 279 + expect(listener).toHaveBeenCalledTimes(1) // Still only once 280 + }) 281 + }) 282 + 283 + describe('clock synchronization scenarios', () => { 284 + it('handles receiving timestamp from faster clock', () => { 285 + const clock = new LogicalClock(identAlice) 286 + 287 + // Local clock generates some timestamps 288 + clock.now() 289 + clock.now() 290 + 291 + // Receive timestamp from remote peer with faster clock 292 + const futureTimestamp = generate(identBob, 9999999999, 100) 293 + clock.tick(futureTimestamp) 294 + 295 + // Subsequent local timestamps should be after the remote one 296 + const nextLocal = clock.now() 297 + expect(compare(futureTimestamp, nextLocal)).toBeLessThanOrEqual(0) 298 + }) 299 + 300 + it('handles receiving timestamp from slower clock', () => { 301 + const clock = new LogicalClock(identAlice) 302 + 303 + // Advance local clock significantly 304 + const localTs = generate(identAlice, 5000, 0) 305 + clock.tick(localTs) 306 + 307 + // Receive old timestamp from slow peer 308 + const oldTimestamp = generate(identBob, 1000, 0) 309 + clock.tick(oldTimestamp) 310 + 311 + // Clock should not regress 312 + expect(clock.latest()).toBe(localTs) 313 + }) 314 + 315 + it('merges concurrent timestamps by identity', () => { 316 + const clock = new LogicalClock(identAlice) 317 + 318 + const ts1 = generate(identAlice, 1000, 0) 319 + const ts2 = generate(identBob, 1000, 0) 320 + 321 + clock.tick(ts1) 322 + clock.tick(ts2) 323 + 324 + // Latest should be whichever is greater 325 + const latest = clock.latest() 326 + const ordering = compare(ts1, ts2) 327 + if (ordering < 0) { 328 + expect(latest).toBe(ts2) 329 + } else if (ordering > 0) { 330 + expect(latest).toBe(ts1) 331 + } else { 332 + // Should not be equal with different identities 333 + expect(ordering).not.toBe(0) 334 + } 335 + }) 336 + }) 337 + 338 + describe('backward clock handling', () => { 339 + it('maintains monotonicity when system clock goes backward', () => { 340 + const clock = new LogicalClock(identAlice) 341 + 342 + // Generate timestamp at current time 343 + const ts1 = clock.now() 344 + const {seconds: s1} = explode(ts1) 345 + 346 + // Simulate system clock going backward by directly ticking with past timestamp 347 + // This tests the clock's resilience but note: now() uses Date.now() which we can't mock easily 348 + // So we test the tick behavior instead 349 + const oldTimestamp = generate(identAlice, s1 - 100, 0) 350 + clock.tick(oldTimestamp) 351 + 352 + // Latest should not regress 353 + expect(clock.latest()).toBe(ts1) 354 + }) 355 + }) 356 + 357 + describe('bootstrapping from existing timestamp', () => { 358 + it('continues from bootstrapped timestamp', () => { 359 + const existingTimestamp = generate(identAlice, 5000, 42) 360 + const clock = new LogicalClock(identAlice, existingTimestamp) 361 + 362 + const newTimestamp = clock.now() 363 + 364 + // New timestamp should be after the bootstrapped one 365 + expect(compare(existingTimestamp, newTimestamp)).toBeLessThanOrEqual(0) 366 + }) 367 + 368 + it('bootstraps with timestamp from different identity', () => { 369 + const bobTimestamp = generate(identBob, 5000, 42) 370 + const clock = new LogicalClock(identAlice, bobTimestamp) 371 + 372 + expect(clock.latest()).toBe(bobTimestamp) 373 + expect(clock.actor).toBe(identAlice) 374 + 375 + // New timestamps should use alice's identity 376 + const newTimestamp = clock.now() 377 + const {identid} = explode(newTimestamp) 378 + expect(identid).toBe(identAlice) 379 + }) 380 + }) 381 + 382 + describe('edge cases', () => { 383 + it('handles counter overflow scenario', () => { 384 + const clock = new LogicalClock(identAlice) 385 + const timestamps = [] 386 + 387 + // Generate many timestamps rapidly 388 + for (let i = 0; i < 1000; i++) { 389 + timestamps.push(clock.now()) 390 + } 391 + 392 + // All should be strictly ordered 393 + for (let i = 0; i < timestamps.length - 1; i++) { 394 + expect(compare(timestamps[i], timestamps[i + 1])).toBeLessThan(0) 395 + } 396 + }) 397 + 398 + it('handles timestamp at epoch zero', () => { 399 + const clock = new LogicalClock(identAlice) 400 + const zeroTimestamp = generate(identAlice, 0, 0) 401 + 402 + clock.tick(zeroTimestamp) 403 + expect(clock.latest()).toBe(zeroTimestamp) 404 + }) 405 + 406 + it('handles very large timestamp values', () => { 407 + const clock = new LogicalClock(identAlice) 408 + const largeTimestamp = generate(identAlice, 9999999999, 999999) 409 + 410 + clock.tick(largeTimestamp) 411 + expect(clock.latest()).toBe(largeTimestamp) 412 + }) 413 + }) 414 + })
+64 -1
src/realm/logical-clock.ts
··· 1 1 import {IdentID} from '#realm/protocol/brands' 2 2 import {compare, generate, Timestamp} from '#realm/protocol/timestamp' 3 3 4 + /** 5 + * A logical clock that generates monotonically increasing timestamps, with identity as tiebreaking. 6 + * 7 + * @example 8 + * ```ts 9 + * const identid = IdentBrand.parse('idt_alice') 10 + * const clock = new LogicalClock(identid) 11 + * 12 + * clock.latest //=> null 13 + * clock.addEventListener('tick', (event) => { 14 + * console.log('Clock advanced:', event.detail) 15 + * }) 16 + * 17 + * const timestamp = clock.now() 18 + * // event fired 19 + * 20 + * clock.latest //=> some timestamp 21 + * 22 + * // got a timestamp from outside? observe it 23 + * clock.tick(receivedTimestamp) 24 + * ``` 25 + */ 4 26 export class LogicalClock extends EventTarget { 5 27 #identid: IdentID 6 28 #counter = 0 7 29 #seconds = 0 8 30 #latest: Timestamp | null = null 9 31 32 + /** 33 + * Triggered when the clock advances. 34 + * @see {@link TickEvent} 35 + * @event 36 + */ 37 + static readonly TICK = 'tick' 38 + 39 + /** 40 + * @param identid - identity for this clock 41 + * @param latest - optional initial timestamp to bootstrap the clock 42 + */ 10 43 constructor(identid: IdentID, latest: Timestamp | null = null) { 11 44 super() 12 45 this.#identid = identid ··· 17 50 return this.#identid 18 51 } 19 52 53 + /** 54 + * @returns The latest timestamp, or null if no timestamps have been generated/observed 55 + */ 20 56 latest(): Timestamp | null { 21 57 return this.#latest 22 58 } 23 59 60 + /** 61 + * @returns a new timestamp representing the current moment 62 + * @remarks 63 + * the generated timestamp is automatically passed to {@link tick} to update the clock state. 64 + */ 24 65 now(): Timestamp { 25 66 const seconds = Math.round(Date.now() / 1000) 26 67 if (seconds === this.#seconds) { ··· 39 80 return this.tick(now) 40 81 } 41 82 83 + /** 84 + * updates the clock with an observed timestamp. 85 + * 86 + * @param timestamp - the timestamp to observe 87 + * @returns the current latest timestamp (may or may not be the provided timestamp) 88 + * 89 + * @remarks 90 + * If the provided timestamp is greater than the current latest timestamp, 91 + * the clock advances and a 'tick' event is dispatched. If the timestamp is 92 + * not newer, the clock state remains unchanged. 93 + */ 42 94 tick(timestamp: Timestamp) { 43 95 if (!this.#latest || compare(this.#latest, timestamp) < 0) { 44 96 this.#latest = timestamp 45 - this.dispatchEvent(new CustomEvent('tick', {detail: timestamp})) 97 + this.dispatchEvent(new TickEvent(LogicalClock.TICK, timestamp)) 46 98 } 47 99 48 100 return this.#latest 49 101 } 50 102 } 103 + 104 + /** 105 + * An event emmitted when the logical clock advances. 106 + * @see {@link LogicalClock.tick} 107 + * @event 108 + */ 109 + export class TickEvent extends CustomEvent<Timestamp> { 110 + constructor(name: string, timestamp: Timestamp) { 111 + super(name, {detail: timestamp}) 112 + } 113 + }
+1 -1
src/realm/protocol/messages-realm.ts
··· 41 41 42 42 export const realmPeerLeftEventSchema = makeEventSchema( 43 43 'realm.peer-left', 44 - z.object({identid: IdentBrand.schema,}), 44 + z.object({identid: IdentBrand.schema}), 45 45 ) 46 46 47 47 ///
+206
src/realm/protocol/timestamp.spec.ts
··· 1 + import {describe, expect, it} from '@jest/globals' 2 + import {IdentBrand} from './brands' 3 + import {compare, explode, generate, logicalClockSchema} from './timestamp' 4 + 5 + describe('timestamp utilities', () => { 6 + describe('generate', () => { 7 + it('creates a valid timestamp with correct format', () => { 8 + const identid = IdentBrand.generate() 9 + const timestamp = generate(identid, 1699564800, 42) 10 + 11 + expect(timestamp).toMatch(/^lc:1699564800:000042:idt\..+$/) 12 + expect(timestamp).toContain(identid) 13 + }) 14 + 15 + it('pads counter with zeros to 6 digits', () => { 16 + const identid = IdentBrand.generate() 17 + 18 + expect(generate(identid, 1000, 0)).toContain('lc:1000:000000:') 19 + expect(generate(identid, 1000, 1)).toContain('lc:1000:000001:') 20 + expect(generate(identid, 1000, 999)).toContain('lc:1000:000999:') 21 + expect(generate(identid, 1000, 999999)).toContain('lc:1000:999999:') 22 + }) 23 + 24 + it('handles large second values', () => { 25 + const identid = IdentBrand.generate() 26 + const largeSeconds = 1699564800 27 + 28 + const timestamp = generate(identid, largeSeconds, 0) 29 + expect(timestamp).toContain('lc:1699564800:') 30 + }) 31 + 32 + it('returns a branded timestamp that validates against schema', () => { 33 + const identid = IdentBrand.generate() 34 + const timestamp = generate(identid, 1699564800, 42) 35 + 36 + expect(() => logicalClockSchema.parse(timestamp)).not.toThrow() 37 + }) 38 + }) 39 + 40 + describe('explode', () => { 41 + it('decomposes a timestamp into its parts', () => { 42 + const identid = IdentBrand.generate() 43 + const timestamp = generate(identid, 1699564800, 42) 44 + 45 + const {identid: extractedId, seconds, counter} = explode(timestamp) 46 + 47 + expect(extractedId).toBe(identid) 48 + expect(seconds).toBe(1699564800) 49 + expect(counter).toBe(42) 50 + }) 51 + 52 + it('correctly parses zero values', () => { 53 + const identid = IdentBrand.generate() 54 + const timestamp = generate(identid, 0, 0) 55 + 56 + const {seconds, counter} = explode(timestamp) 57 + 58 + expect(seconds).toBe(0) 59 + expect(counter).toBe(0) 60 + }) 61 + 62 + it('handles maximum counter values', () => { 63 + const identid = IdentBrand.generate() 64 + const timestamp = generate(identid, 1000, 999999) 65 + 66 + const {counter} = explode(timestamp) 67 + 68 + expect(counter).toBe(999999) 69 + }) 70 + }) 71 + 72 + describe('compare', () => { 73 + const identAlice = IdentBrand.generate() 74 + const identBob = IdentBrand.generate() 75 + 76 + it('returns negative when first timestamp is earlier (by seconds)', () => { 77 + const ts1 = generate(identAlice, 1000, 0) 78 + const ts2 = generate(identAlice, 2000, 0) 79 + 80 + expect(compare(ts1, ts2)).toBeLessThan(0) 81 + }) 82 + 83 + it('returns positive when first timestamp is later (by seconds)', () => { 84 + const ts1 = generate(identAlice, 2000, 0) 85 + const ts2 = generate(identAlice, 1000, 0) 86 + 87 + expect(compare(ts1, ts2)).toBeGreaterThan(0) 88 + }) 89 + 90 + it('compares by counter when seconds are equal', () => { 91 + const ts1 = generate(identAlice, 1000, 5) 92 + const ts2 = generate(identAlice, 1000, 10) 93 + 94 + expect(compare(ts1, ts2)).toBeLessThan(0) 95 + expect(compare(ts2, ts1)).toBeGreaterThan(0) 96 + }) 97 + 98 + it('compares by identid when seconds and counter are equal', () => { 99 + const ts1 = generate(identAlice, 1000, 5) 100 + const ts2 = generate(identBob, 1000, 5) 101 + 102 + const result = compare(ts1, ts2) 103 + 104 + // Result depends on lexicographic ordering of identids 105 + // Consistency: comparing in reverse should give opposite sign 106 + 107 + expect(result).not.toBe(0) 108 + expect(Math.sign(result)).toBe(-Math.sign(compare(ts2, ts1))) 109 + }) 110 + 111 + it('returns zero for identical timestamps', () => { 112 + const ts1 = generate(identAlice, 1000, 5) 113 + const ts2 = generate(identAlice, 1000, 5) 114 + 115 + expect(compare(ts1, ts2)).toBe(0) 116 + }) 117 + 118 + it('provides total ordering (transitivity)', () => { 119 + const ts1 = generate(identAlice, 1000, 1) 120 + const ts2 = generate(identAlice, 1000, 2) 121 + const ts3 = generate(identAlice, 1000, 3) 122 + 123 + // If ts1 < ts2 and ts2 < ts3, then ts1 < ts3 124 + expect(compare(ts1, ts2)).toBeLessThan(0) 125 + expect(compare(ts2, ts3)).toBeLessThan(0) 126 + expect(compare(ts1, ts3)).toBeLessThan(0) 127 + }) 128 + 129 + it('seconds take precedence over counter', () => { 130 + const ts1 = generate(identAlice, 1000, 999999) 131 + const ts2 = generate(identAlice, 1001, 0) 132 + 133 + expect(compare(ts1, ts2)).toBeLessThan(0) 134 + }) 135 + 136 + it('counter takes precedence over identid', () => { 137 + const ts1 = generate(identBob, 1000, 5) // identBob might be > identAlice lexically 138 + const ts2 = generate(identAlice, 1000, 10) 139 + 140 + expect(compare(ts1, ts2)).toBeLessThan(0) 141 + }) 142 + }) 143 + 144 + describe('logicalClockSchema', () => { 145 + it('validates correct timestamp format', () => { 146 + const identid = IdentBrand.generate() 147 + const validTimestamp = generate(identid, 1699564800, 42) 148 + 149 + expect(() => logicalClockSchema.parse(validTimestamp)).not.toThrow() 150 + }) 151 + 152 + it('rejects invalid format', () => { 153 + expect(() => logicalClockSchema.parse('invalid')).toThrow() 154 + expect(() => logicalClockSchema.parse('lc:abc:def:ghi')).toThrow() 155 + }) 156 + 157 + it('rejects incorrect prefix', () => { 158 + const identid = IdentBrand.generate() 159 + const badTimestamp = `hlc:1000:000042:${identid}` 160 + 161 + expect(() => logicalClockSchema.parse(badTimestamp)).toThrow() 162 + }) 163 + 164 + it('validates counter padding', () => { 165 + const identid = IdentBrand.generate() 166 + 167 + expect(() => logicalClockSchema.parse(`lc:1000:000042:${identid}`)).not.toThrow() 168 + expect(() => logicalClockSchema.parse(`lc:1000:42:${identid}`)).toThrow() 169 + expect(() => logicalClockSchema.parse(`lc:1000:0042:${identid}`)).toThrow() 170 + }) 171 + }) 172 + 173 + describe('round-trip consistency', () => { 174 + it('generate and explode are inverse operations', () => { 175 + const identid = IdentBrand.generate() 176 + const originalSeconds = 1699564800 177 + const originalCounter = 42 178 + 179 + const timestamp = generate(identid, originalSeconds, originalCounter) 180 + const {identid: extractedId, seconds, counter} = explode(timestamp) 181 + 182 + expect(extractedId).toBe(identid) 183 + expect(seconds).toBe(originalSeconds) 184 + expect(counter).toBe(originalCounter) 185 + }) 186 + 187 + it('handles edge cases in round-trip', () => { 188 + const identid = IdentBrand.generate() 189 + const testCases = [ 190 + [0, 0], 191 + [1, 1], 192 + [999999, 999999], 193 + [1699564800, 123456], 194 + ] 195 + 196 + for (const [seconds, counter] of testCases) { 197 + const timestamp = generate(identid, seconds, counter) 198 + const exploded = explode(timestamp) 199 + 200 + expect(exploded.identid).toBe(identid) 201 + expect(exploded.seconds).toBe(seconds) 202 + expect(exploded.counter).toBe(counter) 203 + } 204 + }) 205 + }) 206 + })
+30 -3
src/realm/protocol/timestamp.ts
··· 4 4 const pattern = `lc:\\d+:\\d{6}:${IdentBrand.pattern}` 5 5 const regexp = new RegExp(`^${pattern}`) 6 6 7 - // hlc format: 'lc.seconds.counter.identid' 8 - 9 7 export const logicalClockSchema = z.string().regex(regexp).brand(Symbol('hlc')) 10 8 export type Timestamp = z.infer<typeof logicalClockSchema> 11 9 12 10 export const peerClocksSchema = z.record(z.string(), logicalClockSchema.nullable()) 13 11 export type PeerClocks = z.infer<typeof peerClocksSchema> 14 12 13 + /** 14 + * generates a new logical clock timestamp 15 + * 16 + * @param identid - The unique identifier of the actor/node creating the timestamp 17 + * @param seconds - Unix timestamp in seconds 18 + * @param counter - Sub-second counter for ordering events within the same second (0-999999) 19 + * @returns a valid logical clock timestamp 20 + */ 15 21 export function generate(identid: IdentID, seconds: number, counter: number) { 16 22 const counterstr = String(counter).padStart(6, '0') 17 - const secondsstr = seconds.toFixed(0).padStart(6, '0') 23 + const secondsstr = seconds.toFixed(0) 18 24 19 25 const hlcstr = `lc:${secondsstr}:${counterstr}:${identid}` 20 26 return logicalClockSchema.parse(hlcstr) // adds the brand 21 27 } 22 28 29 + /** 30 + * explodes a logical clock timestamp into its constituent parts. 31 + * 32 + * @param input - The timestamp to decompose 33 + * @returns the exploded data 34 + */ 23 35 export function explode(input: Timestamp): {identid: IdentID; seconds: number; counter: number} { 24 36 const [_a, _seconds, _counter, _identid] = input.split(':') 25 37 const seconds = Number(_seconds) ··· 29 41 return {seconds, counter, identid} 30 42 } 31 43 44 + /** 45 + * compares two logical clock timestamps to determine their ordering. 46 + * timestamp identities are used for tiebreaking 47 + * 48 + * @param a - timestamp to compare 49 + * @param b - timestamp to compare 50 + * @returns Negative if a \< b, positive if a \> b, zero if equal 51 + * 52 + * @example 53 + * ```ts 54 + * const ts1 = logicalClockSchema.parse('lc:1699564800:000042:idt_alice') 55 + * const ts2 = logicalClockSchema.parse('lc:1699564800:000043:idt_bob') 56 + * compare(ts1, ts2) // Returns negative (ts1 happened before ts2) 57 + * ``` 58 + */ 32 59 export function compare(a: Timestamp, b: Timestamp): number { 33 60 const [_a, asec, acounter, aident] = a.split(':') 34 61 const [_b, bsec, bcounter, bident] = b.split(':')