liepoch 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 deathg0d
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # liepoch
2
+
3
+ **Distributed timestamps that actually mean something.**
4
+
5
+ Timestamps in distributed systems are lies. Two machines clock a simultaneous event, and due to NTP drift, one says it happened 47 milliseconds before the other. This breaks log causality, scrambles trace ordering, and silently destroys data in Last-Write-Wins databases.
6
+
7
+ `liepoch` is a zero-dependency, zero-ceremony **Hybrid Logical Clock (HLC)** for Node.js and TypeScript. It packs a physical timestamp and a logical causality counter into a single 64-bit integer, serialized as a safe, universally sortable string.
8
+
9
+ If `liepoch` becomes the standard, suddenly all your microservices, background workers, and distributed databases speak the exact same temporal language.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install liepoch
15
+ ```
16
+
17
+ ## The "Zero Ceremony" Guarantees
18
+ Every distributed system rolls its own HLC, usually with fatal edge cases. `liepoch` is designed to be a foolproof drop-in primitive:
19
+ * **JSON Safe:** Returns fixed-width hex strings (`"0000018f2a1b0001"`). No `TypeError: Do not know how to serialize a BigInt` crashes.
20
+ * **Database Safe:** Because they are zero-padded 16-character strings, they **sort natively and correctly** in standard `VARCHAR` columns, MongoDB strings, and Redis sorted sets without needing custom comparison logic.
21
+ * **Type Safe:** Explicitly rejects standard JavaScript `Number` types (like `Date.now()`) to prevent silent precision loss beyond 53 bits.
22
+ * **Drift Protected:** Bounds severe backward NTP drift and malicious future-dated messages.
23
+
24
+ ---
25
+
26
+ ## Quick Start
27
+
28
+ By default, `liepoch` exports a singleton. You use `stamp()` when an event happens, and `receive()` when you get a message from another machine.
29
+
30
+ ### Service A (The Sender)
31
+ ```typescript
32
+ import { stamp } from 'liepoch';
33
+
34
+ app.post('/checkout', (req, res) => {
35
+ // Generate a causal timestamp for this event
36
+ const eventTime = stamp();
37
+
38
+ // Send to Kafka/RabbitMQ/Redis/etc
39
+ messageQueue.publish('orders', {
40
+ id: 'order_123',
41
+ _causality: eventTime,
42
+ data: req.body
43
+ });
44
+ });
45
+ ```
46
+
47
+ ### Service B (The Receiver)
48
+ ```typescript
49
+ import { receive } from 'liepoch';
50
+
51
+ messageQueue.subscribe('orders', (msg) => {
52
+ // Absorb the timestamp from Service A.
53
+ // If Service B's physical clock is running slightly slow,
54
+ // receive() forces Service B's clock into the correct causal future.
55
+ const localTime = receive(msg._causality);
56
+
57
+ // localTime is strictly causally > msg._causality
58
+ db.save({
59
+ ...msg.data,
60
+ updated_at: localTime
61
+ });
62
+ });
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Use Cases
68
+
69
+ ### 1. Correcting Microservice Logs (No More Time Travel)
70
+ If Service A logs an event, forwards it to Worker B, and Worker B's clock is 15ms behind, standard logs will show the worker processing the event *before* the user even clicked the button.
71
+
72
+ By passing `liepoch` timestamps between services via HTTP headers or queue payloads, and calling `receive()` on the worker, cause will **always** sort strictly before effect in Datadog, Splunk, or ElasticSearch.
73
+
74
+ ### 2. Preventing Silent Data Loss (Last-Write-Wins)
75
+ In a distributed database (Cassandra, DynamoDB, or multi-writer Postgres), conflict resolution often relies on timestamps. If a user updates their profile on a server with a fast clock, and immediately fixes a typo on a server with an accurate clock, the database will incorrectly keep the older data because its physical timestamp is higher.
76
+
77
+ Using `liepoch` ensures that the second write is causally stamped higher than the first write, preserving the user's intent perfectly.
78
+
79
+ ### 3. Same-Millisecond Collisions
80
+ If a busy Node.js loop processes 50 events in a single millisecond, `Date.now()` gives them all the exact same timestamp. When saved to a database, their order is permanently lost.
81
+
82
+ `liepoch` uses the bottom 16 bits as a logical counter. All 50 events get the same physical time, but mathematically unique, strictly incrementing logical counters. Order is preserved flawlessly.
83
+
84
+ ---
85
+
86
+ ## API Reference
87
+
88
+ ### `stamp(): string`
89
+ Generates a new timestamp string representing "now". Call this when a local event occurs.
90
+
91
+ ### `receive(incoming: string | bigint): string`
92
+ Absorbs a remote timestamp, ensuring the local clock advances past it. Call this whenever you receive a message/request from another node.
93
+
94
+ ### `before(a: string | bigint, b: string | bigint): boolean`
95
+ Returns `true` if event `a` happened causally before event `b`. Safely handles mixed inputs (raw hex, `0x`-prefixed hex, or BigInts).
96
+
97
+ ### `unpack(stamp: string | bigint): { time_ms: number, logical: number, date: Date }`
98
+ Unpacks a `liepoch` timestamp into its physical millisecond time and its logical counter. Useful for human-readable debugging.
99
+
100
+ ### `clock: Liepoch`
101
+ The underlying singleton instance. You can instantiate your own (`new Liepoch()`) if you need isolated clocks for unit testing, but 99% of applications should use the default singleton to maintain correct causality across the entire Node process.
102
+
103
+ ---
104
+
105
+ ## How it works under the hood
106
+
107
+ A `liepoch` timestamp is a 64-bit integer:
108
+ * **Top 48 bits**: Physical wall-clock time in milliseconds since the UNIX epoch (safe until the year 10,889 AD).
109
+ * **Bottom 16 bits**: Logical counter (allows 65,536 causal events within the exact same millisecond).
110
+
111
+ Because 64-bit integers lose precision in JavaScript's floating-point numbers, the public API deals entirely in 16-character, zero-padded hex strings.
112
+
113
+ ```
114
+ 0000018f2a1b0001
115
+ [ time ms ][log]
116
+ ```
117
+
118
+ ## Ephemeral State & Restarts
119
+ The singleton's state resides in memory. If your Node process crashes and restarts, it forgets its previous logical state. To guarantee absolute causality across process restarts in a high-throughput microservice, you should fetch the last known `liepoch` timestamp for this node from your database/store on boot, and pass it to `receive()` before processing new events.
120
+
121
+ ## License
122
+ MIT
@@ -0,0 +1,44 @@
1
+ export declare class LiepochError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class Liepoch {
5
+ private wallTime;
6
+ private logical;
7
+ private static readonly MAX_LOGICAL;
8
+ private static readonly MAX_DRIFT_MS;
9
+ private static readonly PAD_LENGTH;
10
+ private now;
11
+ /**
12
+ * Serialize a BigInt stamp to a fixed-width, zero-padded lowercase hex string.
13
+ * 16 hex chars = 64 bits. Sorts correctly lexicographically in any DB or index.
14
+ * Safe round-trip via deserialize().
15
+ */
16
+ static serialize(stamp: bigint): string;
17
+ /**
18
+ * Deserialize a stamp string back to BigInt.
19
+ * Accepts bare hex ("0000018f..."), prefixed ("0x0000018f..."),
20
+ * and uppercase hex ("0000018F...").
21
+ */
22
+ static deserialize(stamp: string): bigint;
23
+ /**
24
+ * Used primarily for testing to explicitly set the internal clock.
25
+ */
26
+ _setTestingState(wallTime: bigint, logical: bigint): void;
27
+ stamp(): bigint;
28
+ receive(incoming: bigint | string): bigint;
29
+ /**
30
+ * Unpack a timestamp into human-readable components.
31
+ */
32
+ static unpack(stamp: bigint | string): {
33
+ time_ms: number;
34
+ logical: number;
35
+ date: Date;
36
+ };
37
+ }
38
+ export declare const clock: Liepoch;
39
+ export declare const stamp: () => string;
40
+ export declare const receive: (ts: string | bigint) => string;
41
+ export declare const unpack: typeof Liepoch.unpack;
42
+ export declare const serialize: typeof Liepoch.serialize;
43
+ export declare const deserialize: typeof Liepoch.deserialize;
44
+ export declare const before: (a: string | bigint, b: string | bigint) => boolean;
package/dist/index.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.before = exports.deserialize = exports.serialize = exports.unpack = exports.receive = exports.stamp = exports.clock = exports.Liepoch = exports.LiepochError = void 0;
4
+ class LiepochError extends Error {
5
+ constructor(message) {
6
+ super(`Liepoch: ${message}`);
7
+ this.name = 'LiepochError';
8
+ }
9
+ }
10
+ exports.LiepochError = LiepochError;
11
+ class Liepoch {
12
+ wallTime = 0n;
13
+ logical = 0n;
14
+ static MAX_LOGICAL = 0xffffn;
15
+ static MAX_DRIFT_MS = 300000n;
16
+ static PAD_LENGTH = 16;
17
+ now() {
18
+ return BigInt(Date.now());
19
+ }
20
+ /**
21
+ * Serialize a BigInt stamp to a fixed-width, zero-padded lowercase hex string.
22
+ * 16 hex chars = 64 bits. Sorts correctly lexicographically in any DB or index.
23
+ * Safe round-trip via deserialize().
24
+ */
25
+ static serialize(stamp) {
26
+ return stamp.toString(16).padStart(Liepoch.PAD_LENGTH, '0');
27
+ }
28
+ /**
29
+ * Deserialize a stamp string back to BigInt.
30
+ * Accepts bare hex ("0000018f..."), prefixed ("0x0000018f..."),
31
+ * and uppercase hex ("0000018F...").
32
+ */
33
+ static deserialize(stamp) {
34
+ const clean = stamp.replace(/^0x/i, '');
35
+ if (!/^[0-9a-f]+$/i.test(clean)) {
36
+ throw new LiepochError("Invalid hex string provided to deserialize().");
37
+ }
38
+ return BigInt('0x' + clean);
39
+ }
40
+ /**
41
+ * Used primarily for testing to explicitly set the internal clock.
42
+ */
43
+ _setTestingState(wallTime, logical) {
44
+ this.wallTime = wallTime;
45
+ this.logical = logical;
46
+ }
47
+ stamp() {
48
+ const currentWall = this.now();
49
+ let nextWall = this.wallTime;
50
+ let nextLogical = this.logical;
51
+ if (currentWall > this.wallTime) {
52
+ nextWall = currentWall;
53
+ nextLogical = 0n;
54
+ }
55
+ else {
56
+ if (this.wallTime - currentWall > Liepoch.MAX_DRIFT_MS) {
57
+ throw new LiepochError(`Local physical clock is ${this.wallTime - currentWall}ms behind the last ` +
58
+ `recorded wall time, exceeding MAX_DRIFT_MS (${Liepoch.MAX_DRIFT_MS}ms). ` +
59
+ `This usually means NTP jumped the clock backward. ` +
60
+ `Restart the process once the clock stabilizes.`);
61
+ }
62
+ nextLogical++;
63
+ if (nextLogical > Liepoch.MAX_LOGICAL) {
64
+ throw new LiepochError("Logical clock overflow in stamp(). Throughput too high for 16-bit counter.");
65
+ }
66
+ }
67
+ this.wallTime = nextWall;
68
+ this.logical = nextLogical;
69
+ return (this.wallTime << 16n) | this.logical;
70
+ }
71
+ receive(incoming) {
72
+ if (typeof incoming !== 'bigint' && typeof incoming !== 'string') {
73
+ throw new LiepochError(`receive() requires a BigInt or String. Got: ${typeof incoming}. ` +
74
+ `If reading from a JSON payload, the sender must serialize the timestamp ` +
75
+ `as a string before JSON.stringify(). Wrapping an already-parsed number ` +
76
+ `in String() will not recover lost precision.`);
77
+ }
78
+ const inc = typeof incoming === 'string' ? Liepoch.deserialize(incoming) : incoming;
79
+ const currentWall = this.now();
80
+ const msgTime = inc >> 16n;
81
+ const msgLogical = inc & 0xffffn;
82
+ if (msgTime > currentWall && msgTime - currentWall > Liepoch.MAX_DRIFT_MS) {
83
+ throw new LiepochError(`Incoming timestamp is ${msgTime - currentWall}ms in the future, ` +
84
+ `exceeding MAX_DRIFT_MS (${Liepoch.MAX_DRIFT_MS}ms).`);
85
+ }
86
+ let maxTime = currentWall;
87
+ if (this.wallTime > maxTime)
88
+ maxTime = this.wallTime;
89
+ if (msgTime > maxTime)
90
+ maxTime = msgTime;
91
+ let nextLogical;
92
+ if (maxTime === this.wallTime && maxTime === msgTime) {
93
+ nextLogical = (this.logical > msgLogical ? this.logical : msgLogical) + 1n;
94
+ }
95
+ else if (maxTime === this.wallTime) {
96
+ nextLogical = this.logical + 1n;
97
+ }
98
+ else if (maxTime === msgTime) {
99
+ nextLogical = msgLogical + 1n;
100
+ }
101
+ else {
102
+ nextLogical = 0n;
103
+ }
104
+ if (nextLogical > Liepoch.MAX_LOGICAL) {
105
+ throw new LiepochError("Logical clock overflow in receive(). State has not been mutated.");
106
+ }
107
+ this.wallTime = maxTime;
108
+ this.logical = nextLogical;
109
+ return (this.wallTime << 16n) | this.logical;
110
+ }
111
+ /**
112
+ * Unpack a timestamp into human-readable components.
113
+ */
114
+ static unpack(stamp) {
115
+ const val = typeof stamp === 'string' ? Liepoch.deserialize(stamp) : stamp;
116
+ const time_ms = Number(val >> 16n);
117
+ return {
118
+ time_ms,
119
+ logical: Number(val & 0xffffn),
120
+ date: new Date(time_ms)
121
+ };
122
+ }
123
+ }
124
+ exports.Liepoch = Liepoch;
125
+ // ---------------------------------------------------------
126
+ // Default singleton + ergonomic string API
127
+ // ---------------------------------------------------------
128
+ exports.clock = new Liepoch();
129
+ const stamp = () => Liepoch.serialize(exports.clock.stamp());
130
+ exports.stamp = stamp;
131
+ const receive = (ts) => Liepoch.serialize(exports.clock.receive(ts));
132
+ exports.receive = receive;
133
+ exports.unpack = Liepoch.unpack;
134
+ exports.serialize = Liepoch.serialize;
135
+ exports.deserialize = Liepoch.deserialize;
136
+ const before = (a, b) => {
137
+ const valA = typeof a === 'bigint' ? a : Liepoch.deserialize(a);
138
+ const valB = typeof b === 'bigint' ? b : Liepoch.deserialize(b);
139
+ return valA < valB;
140
+ };
141
+ exports.before = before;
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "liepoch",
3
+ "version": "1.0.0",
4
+ "description": "Distributed timestamps that actually mean something. A zero-dependency Hybrid Logical Clock (HLC).",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "test": "node --import tsx --test test/**/*.test.ts",
10
+ "prepublishOnly": "npm run build && npm test"
11
+ },
12
+ "keywords": [
13
+ "timestamp",
14
+ "distributed-systems",
15
+ "hlc",
16
+ "clock",
17
+ "causality",
18
+ "microservices",
19
+ "logs"
20
+ ],
21
+ "author": "Open Source Community",
22
+ "license": "MIT",
23
+ "devDependencies": {
24
+ "@types/node": "^20.0.0",
25
+ "typescript": "^5.4.0",
26
+ "tsx": "^4.7.0"
27
+ }
28
+ }
package/src/index.ts ADDED
@@ -0,0 +1,161 @@
1
+ export class LiepochError extends Error {
2
+ constructor(message: string) {
3
+ super(`Liepoch: ${message}`);
4
+ this.name = 'LiepochError';
5
+ }
6
+ }
7
+
8
+ export class Liepoch {
9
+ private wallTime: bigint = 0n;
10
+ private logical: bigint = 0n;
11
+
12
+ private static readonly MAX_LOGICAL = 0xFFFFn;
13
+ private static readonly MAX_DRIFT_MS = 300000n;
14
+ private static readonly PAD_LENGTH = 16;
15
+
16
+ private now(): bigint {
17
+ return BigInt(Date.now());
18
+ }
19
+
20
+ /**
21
+ * Serialize a BigInt stamp to a fixed-width, zero-padded lowercase hex string.
22
+ * 16 hex chars = 64 bits. Sorts correctly lexicographically in any DB or index.
23
+ * Safe round-trip via deserialize().
24
+ */
25
+ public static serialize(stamp: bigint): string {
26
+ return stamp.toString(16).padStart(Liepoch.PAD_LENGTH, '0');
27
+ }
28
+
29
+ /**
30
+ * Deserialize a stamp string back to BigInt.
31
+ * Accepts bare hex ("0000018f..."), prefixed ("0x0000018f..."),
32
+ * and uppercase hex ("0000018F...").
33
+ */
34
+ public static deserialize(stamp: string): bigint {
35
+ const clean = stamp.replace(/^0x/i, '');
36
+ if (!/^[0-9a-f]+$/i.test(clean)) {
37
+ throw new LiepochError("Invalid hex string provided to deserialize().");
38
+ }
39
+ return BigInt('0x' + clean);
40
+ }
41
+
42
+ /**
43
+ * Used primarily for testing to explicitly set the internal clock.
44
+ */
45
+ public _setTestingState(wallTime: bigint, logical: bigint): void {
46
+ this.wallTime = wallTime;
47
+ this.logical = logical;
48
+ }
49
+
50
+ public stamp(): bigint {
51
+ const currentWall = this.now();
52
+
53
+ let nextWall = this.wallTime;
54
+ let nextLogical = this.logical;
55
+
56
+ if (currentWall > this.wallTime) {
57
+ nextWall = currentWall;
58
+ nextLogical = 0n;
59
+ } else {
60
+ if (this.wallTime - currentWall > Liepoch.MAX_DRIFT_MS) {
61
+ throw new LiepochError(
62
+ `Local physical clock is ${this.wallTime - currentWall}ms behind the last ` +
63
+ `recorded wall time, exceeding MAX_DRIFT_MS (${Liepoch.MAX_DRIFT_MS}ms). ` +
64
+ `This usually means NTP jumped the clock backward. ` +
65
+ `Restart the process once the clock stabilizes.`
66
+ );
67
+ }
68
+ nextLogical++;
69
+ if (nextLogical > Liepoch.MAX_LOGICAL) {
70
+ throw new LiepochError(
71
+ "Logical clock overflow in stamp(). Throughput too high for 16-bit counter."
72
+ );
73
+ }
74
+ }
75
+
76
+ this.wallTime = nextWall;
77
+ this.logical = nextLogical;
78
+
79
+ return (this.wallTime << 16n) | this.logical;
80
+ }
81
+
82
+ public receive(incoming: bigint | string): bigint {
83
+ if (typeof incoming !== 'bigint' && typeof incoming !== 'string') {
84
+ throw new LiepochError(
85
+ `receive() requires a BigInt or String. Got: ${typeof incoming}. ` +
86
+ `If reading from a JSON payload, the sender must serialize the timestamp ` +
87
+ `as a string before JSON.stringify(). Wrapping an already-parsed number ` +
88
+ `in String() will not recover lost precision.`
89
+ );
90
+ }
91
+
92
+ const inc = typeof incoming === 'string' ? Liepoch.deserialize(incoming) : incoming;
93
+ const currentWall = this.now();
94
+ const msgTime = inc >> 16n;
95
+ const msgLogical = inc & 0xFFFFn;
96
+
97
+ if (msgTime > currentWall && msgTime - currentWall > Liepoch.MAX_DRIFT_MS) {
98
+ throw new LiepochError(
99
+ `Incoming timestamp is ${msgTime - currentWall}ms in the future, ` +
100
+ `exceeding MAX_DRIFT_MS (${Liepoch.MAX_DRIFT_MS}ms).`
101
+ );
102
+ }
103
+
104
+ let maxTime = currentWall;
105
+ if (this.wallTime > maxTime) maxTime = this.wallTime;
106
+ if (msgTime > maxTime) maxTime = msgTime;
107
+
108
+ let nextLogical: bigint;
109
+
110
+ if (maxTime === this.wallTime && maxTime === msgTime) {
111
+ nextLogical = (this.logical > msgLogical ? this.logical : msgLogical) + 1n;
112
+ } else if (maxTime === this.wallTime) {
113
+ nextLogical = this.logical + 1n;
114
+ } else if (maxTime === msgTime) {
115
+ nextLogical = msgLogical + 1n;
116
+ } else {
117
+ nextLogical = 0n;
118
+ }
119
+
120
+ if (nextLogical > Liepoch.MAX_LOGICAL) {
121
+ throw new LiepochError(
122
+ "Logical clock overflow in receive(). State has not been mutated."
123
+ );
124
+ }
125
+
126
+ this.wallTime = maxTime;
127
+ this.logical = nextLogical;
128
+
129
+ return (this.wallTime << 16n) | this.logical;
130
+ }
131
+
132
+ /**
133
+ * Unpack a timestamp into human-readable components.
134
+ */
135
+ public static unpack(stamp: bigint | string): { time_ms: number; logical: number; date: Date } {
136
+ const val = typeof stamp === 'string' ? Liepoch.deserialize(stamp) : stamp;
137
+ const time_ms = Number(val >> 16n);
138
+ return {
139
+ time_ms,
140
+ logical : Number(val & 0xFFFFn),
141
+ date : new Date(time_ms)
142
+ };
143
+ }
144
+ }
145
+
146
+ // ---------------------------------------------------------
147
+ // Default singleton + ergonomic string API
148
+ // ---------------------------------------------------------
149
+ export const clock = new Liepoch();
150
+
151
+ export const stamp = (): string => Liepoch.serialize(clock.stamp());
152
+ export const receive = (ts: string | bigint): string => Liepoch.serialize(clock.receive(ts));
153
+ export const unpack = Liepoch.unpack;
154
+ export const serialize = Liepoch.serialize;
155
+ export const deserialize = Liepoch.deserialize;
156
+
157
+ export const before = (a: string | bigint, b: string | bigint): boolean => {
158
+ const valA = typeof a === 'bigint' ? a : Liepoch.deserialize(a);
159
+ const valB = typeof b === 'bigint' ? b : Liepoch.deserialize(b);
160
+ return valA < valB;
161
+ };
@@ -0,0 +1,140 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Liepoch, LiepochError, serialize, deserialize, before, unpack } from '../src/index';
4
+
5
+ test('Liepoch Core Functionality', async (t) => {
6
+
7
+ await t.test('Basic stamping advances time', () => {
8
+ let mockTimeMs = 1718838420000;
9
+ const originalNow = Date.now;
10
+ Date.now = () => mockTimeMs;
11
+
12
+ const clock = new Liepoch();
13
+ const s1 = clock.stamp();
14
+ mockTimeMs += 10;
15
+ const s2 = clock.stamp();
16
+
17
+ assert.ok(s1 < s2, "s2 should be strictly greater than s1");
18
+
19
+ Date.now = originalNow;
20
+ });
21
+
22
+ await t.test('Logical clock increments on same ms', () => {
23
+ let mockTimeMs = 1000;
24
+ const originalNow = Date.now;
25
+ Date.now = () => mockTimeMs;
26
+
27
+ const clock = new Liepoch();
28
+ const s1 = clock.stamp();
29
+ const s2 = clock.stamp();
30
+
31
+ const unpacked1 = Liepoch.unpack(s1);
32
+ const unpacked2 = Liepoch.unpack(s2);
33
+
34
+ assert.equal(unpacked1.time_ms, 1000);
35
+ assert.equal(unpacked1.logical, 0);
36
+
37
+ assert.equal(unpacked2.time_ms, 1000);
38
+ assert.equal(unpacked2.logical, 1);
39
+
40
+ Date.now = originalNow;
41
+ });
42
+
43
+ await t.test('Serialization and Deserialization', () => {
44
+ const stamp = (1000n << 16n) | 5n;
45
+ const hex = serialize(stamp);
46
+
47
+ assert.equal(hex.length, 16, "Should be 16 chars long");
48
+ assert.equal(deserialize(hex), stamp, "Deserialization should be symmetric");
49
+ assert.equal(deserialize("0x" + hex), stamp, "Deserialization should handle 0x prefix");
50
+ assert.equal(deserialize(hex.toUpperCase()), stamp, "Deserialization should handle uppercase hex");
51
+
52
+ assert.throws(() => deserialize("not-hex"), LiepochError, "Should throw on invalid hex");
53
+ });
54
+
55
+ await t.test('receive() absorbs future time', () => {
56
+ let mockTimeMs = 1000;
57
+ const originalNow = Date.now;
58
+ Date.now = () => mockTimeMs;
59
+
60
+ const clock = new Liepoch();
61
+ clock.stamp(); // internal time is now 1000, 0
62
+
63
+ const remoteStamp = (1050n << 16n) | 0n; // Remote is at 1050ms
64
+ const r1 = clock.receive(remoteStamp);
65
+ const unpackedR1 = Liepoch.unpack(r1);
66
+
67
+ assert.equal(unpackedR1.time_ms, 1050);
68
+ assert.equal(unpackedR1.logical, 1, "Logical should increment remote logical because max == remote");
69
+
70
+ Date.now = originalNow;
71
+ });
72
+
73
+ await t.test('receive() with same time takes max logical', () => {
74
+ let mockTimeMs = 1000;
75
+ const originalNow = Date.now;
76
+ Date.now = () => mockTimeMs;
77
+
78
+ const clock = new Liepoch();
79
+ clock.stamp();
80
+ clock.stamp(); // internal is 1000, 1
81
+
82
+ const remoteStamp = (1000n << 16n) | 5n; // Remote is at 1000ms, logical 5
83
+ const r2 = clock.receive(remoteStamp);
84
+ const unpackedR2 = Liepoch.unpack(r2);
85
+
86
+ assert.equal(unpackedR2.time_ms, 1000);
87
+ assert.equal(unpackedR2.logical, 6, "Should take max(local, remote) logical + 1");
88
+
89
+ Date.now = originalNow;
90
+ });
91
+
92
+ await t.test('Logical overflow protection', () => {
93
+ let mockTimeMs = 1000;
94
+ const originalNow = Date.now;
95
+ Date.now = () => mockTimeMs;
96
+
97
+ const clock = new Liepoch();
98
+ clock._setTestingState(1000n, 0xFFFFn); // set to max logical
99
+
100
+ assert.throws(() => clock.stamp(), LiepochError, "Should throw on overflow");
101
+
102
+ Date.now = originalNow;
103
+ });
104
+
105
+ await t.test('Max drift protection', () => {
106
+ let mockTimeMs = 1000;
107
+ const originalNow = Date.now;
108
+ Date.now = () => mockTimeMs;
109
+
110
+ const clock = new Liepoch();
111
+ clock.stamp();
112
+
113
+ // Simulate local clock jumping back severely
114
+ clock._setTestingState(BigInt(mockTimeMs + 300000 + 10), 0n);
115
+ assert.throws(() => clock.stamp(), LiepochError, "Should throw on severe backward drift");
116
+
117
+ // Forward drift from remote
118
+ clock._setTestingState(1000n, 0n);
119
+ const farFuture = (BigInt(mockTimeMs + 300000 + 10) << 16n) | 0n;
120
+ assert.throws(() => clock.receive(farFuture), LiepochError, "Should throw on incoming severe forward drift");
121
+
122
+ Date.now = originalNow;
123
+ });
124
+
125
+ await t.test('before() function handling', () => {
126
+ const b1 = (1000n << 16n) | 5n;
127
+ const b2 = (1000n << 16n) | 6n;
128
+
129
+ assert.ok(before(b1, b2), "b1 before b2");
130
+ assert.ok(before(serialize(b1), serialize(b2)), "string serialization before works");
131
+ assert.ok(before("0x" + serialize(b1), serialize(b2)), "handles mixed prefixes safely");
132
+ assert.ok(!before("0x" + serialize(b2), serialize(b1)), "handles mixed prefixes correctly for false");
133
+ });
134
+
135
+ await t.test('Reject JS Numbers', () => {
136
+ const clock = new Liepoch();
137
+ // @ts-ignore
138
+ assert.throws(() => clock.receive(123), LiepochError, "Should reject JS Number");
139
+ });
140
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "CommonJS",
5
+ "declaration": true,
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true
12
+ },
13
+ "include": ["src/**/*"],
14
+ "exclude": ["node_modules", "test"]
15
+ }