@peers-app/peers-sdk 0.23.12 → 0.23.13
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/dist/data/change-tracking.d.ts +57 -2
- package/dist/data/change-tracking.js +110 -5
- package/dist/device/connection.js +2 -2
- package/dist/hlc.d.ts +65 -0
- package/dist/hlc.js +77 -0
- package/dist/hlc.test.d.ts +1 -0
- package/dist/hlc.test.js +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types/peer-device.d.ts +8 -0
- package/dist/types/peer-device.js +8 -0
- package/package.json +1 -1
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { Emitter, type Event } from "../events";
|
|
10
|
+
import { HybridLogicalClock } from "../hlc";
|
|
10
11
|
import { type DataFilter, type IDataQueryParams, type ISqlDb, type ITableMetaData, SQLDataSource } from "./orm";
|
|
12
|
+
/**
|
|
13
|
+
* Zod schema for a single granular change-log row in `ChangeTrackingV2`.
|
|
14
|
+
* `appliedAt` is assigned locally at insert time; incoming values are replaced.
|
|
15
|
+
*/
|
|
11
16
|
export declare const changeRecordSchema: z.ZodObject<{
|
|
12
17
|
changeId: z.ZodEffects<z.ZodString, string, string>;
|
|
13
18
|
transactionId: z.ZodOptional<z.ZodString>;
|
|
@@ -42,13 +47,57 @@ export declare const changeRecordSchema: z.ZodObject<{
|
|
|
42
47
|
transactionId?: string | undefined;
|
|
43
48
|
supersededAt?: number | undefined;
|
|
44
49
|
}>;
|
|
50
|
+
/**
|
|
51
|
+
* One JSON-patch operation (or root snapshot / delete) stored for synchronization.
|
|
52
|
+
*/
|
|
45
53
|
export type IChangeRecord = z.infer<typeof changeRecordSchema>;
|
|
54
|
+
/**
|
|
55
|
+
* Local-only change log used for peer sync.
|
|
56
|
+
*
|
|
57
|
+
* Owns two {@link HybridLogicalClock} instances for this database:
|
|
58
|
+
* - {@link clock} stamps `createdAt` / `supersededAt` (cross-device LWW)
|
|
59
|
+
* - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
|
|
60
|
+
*/
|
|
46
61
|
export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
47
62
|
readonly dataChangedEmitter: Emitter<IChangeRecord>;
|
|
48
63
|
readonly dataChanged: Event<IChangeRecord>;
|
|
49
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
|
|
66
|
+
* Observes accepted incoming `createdAt` values so later local writes order after them.
|
|
67
|
+
*/
|
|
68
|
+
readonly clock: HybridLogicalClock;
|
|
69
|
+
private readonly appliedClock;
|
|
70
|
+
private clockInit;
|
|
71
|
+
/**
|
|
72
|
+
* @param db - SQLite database that stores `ChangeTrackingV2`
|
|
73
|
+
* @param now - Optional physical-time source shared by both clocks (defaults to {@link getTimestamp})
|
|
74
|
+
*/
|
|
75
|
+
constructor({ db, now }: {
|
|
50
76
|
db: ISqlDb;
|
|
77
|
+
now?: () => number;
|
|
51
78
|
});
|
|
79
|
+
/**
|
|
80
|
+
* Ensures the table exists, seeds both clocks from durable history, and
|
|
81
|
+
* rewrites any far-future `createdAt` rows this device still owns.
|
|
82
|
+
*
|
|
83
|
+
* @param force - Re-run table migration and clock seeding
|
|
84
|
+
*/
|
|
85
|
+
initTable(force?: boolean): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Drop the change-tracking table and clear memoized clock seeding so the next
|
|
88
|
+
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
89
|
+
*/
|
|
90
|
+
dropTableIfExists(): Promise<void>;
|
|
91
|
+
private seedClocksAndRepair;
|
|
92
|
+
/**
|
|
93
|
+
* Rewrite local rows whose `createdAt` is beyond the change-timestamp bound.
|
|
94
|
+
* `createdAt` is restamped from {@link clock} (near wall time) and `appliedAt`
|
|
95
|
+
* from the unbounded apply clock so peers whose watermark already passed the
|
|
96
|
+
* old value will pull the repaired rows.
|
|
97
|
+
*
|
|
98
|
+
* @returns Number of rows rewritten
|
|
99
|
+
*/
|
|
100
|
+
repairFutureTimestamps(): Promise<number>;
|
|
52
101
|
/**
|
|
53
102
|
* Efficiently find which recordIds have been deleted.
|
|
54
103
|
*
|
|
@@ -79,6 +128,10 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
79
128
|
* @returns Array of changeIds with no change records
|
|
80
129
|
*/
|
|
81
130
|
filterToMissingChangeIds(changeIds: string[]): Promise<string[]>;
|
|
131
|
+
/**
|
|
132
|
+
* Mark every still-active change for `recordId` whose `createdAt` is older
|
|
133
|
+
* than `supersededAt`. Used after a root snapshot, delete, or restore.
|
|
134
|
+
*/
|
|
82
135
|
markAllPriorChangesSuperseded(tableName: string, recordId: string, supersededAt: number): Promise<void>;
|
|
83
136
|
/**
|
|
84
137
|
* Batch update supersededAt timestamps for multiple changes.
|
|
@@ -99,7 +152,8 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
99
152
|
*/
|
|
100
153
|
get(id: string): Promise<IChangeRecord | undefined>;
|
|
101
154
|
/**
|
|
102
|
-
*
|
|
155
|
+
* Insert a change row, assigning `appliedAt` from the local apply clock
|
|
156
|
+
* immediately before the SQL statement so watermarks stay monotonic.
|
|
103
157
|
*/
|
|
104
158
|
insert(record: IChangeRecord): Promise<IChangeRecord>;
|
|
105
159
|
/**
|
|
@@ -109,6 +163,7 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
109
163
|
/**
|
|
110
164
|
* Synchronous insert for use within transactions.
|
|
111
165
|
* Table must be initialized before calling this method.
|
|
166
|
+
* Assigns `appliedAt` from the local apply clock immediately before the SQL statement.
|
|
112
167
|
*/
|
|
113
168
|
insertSync(record: IChangeRecord): IChangeRecord;
|
|
114
169
|
/**
|
|
@@ -10,10 +10,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
10
10
|
exports.ChangeTrackingTable = exports.changeRecordSchema = void 0;
|
|
11
11
|
const zod_1 = require("zod");
|
|
12
12
|
const events_1 = require("../events");
|
|
13
|
+
const hlc_1 = require("../hlc");
|
|
13
14
|
const serial_json_1 = require("../serial-json");
|
|
15
|
+
const peer_device_1 = require("../types/peer-device");
|
|
14
16
|
const zod_types_1 = require("../types/zod-types");
|
|
15
17
|
const utils_1 = require("../utils");
|
|
16
18
|
const orm_1 = require("./orm");
|
|
19
|
+
/**
|
|
20
|
+
* Zod schema for a single granular change-log row in `ChangeTrackingV2`.
|
|
21
|
+
* `appliedAt` is assigned locally at insert time; incoming values are replaced.
|
|
22
|
+
*/
|
|
17
23
|
exports.changeRecordSchema = zod_1.z.object({
|
|
18
24
|
changeId: zod_types_1.zodPeerId,
|
|
19
25
|
transactionId: zod_1.z.string().optional(), // not used for now
|
|
@@ -25,9 +31,11 @@ exports.changeRecordSchema = zod_1.z.object({
|
|
|
25
31
|
.any()
|
|
26
32
|
.nullish()
|
|
27
33
|
.describe("JSON value - can be any type (object, string, number, boolean, array, null, or undefined)"),
|
|
28
|
-
createdAt: zod_1.z.number(),
|
|
29
|
-
appliedAt: zod_1.z
|
|
30
|
-
|
|
34
|
+
createdAt: zod_1.z.number().describe("Author write time from the hybrid logical clock (LWW key)"),
|
|
35
|
+
appliedAt: zod_1.z
|
|
36
|
+
.number()
|
|
37
|
+
.describe("Local apply / sync-visibility time; assigned at insert, incoming values replaced"),
|
|
38
|
+
supersededAt: zod_1.z.number().optional().describe("When this change was superseded; unset if active"),
|
|
31
39
|
});
|
|
32
40
|
const changeTrackingV2TableMetaData = {
|
|
33
41
|
name: "ChangeTrackingV2",
|
|
@@ -47,15 +55,103 @@ const changeTrackingV2TableMetaData = {
|
|
|
47
55
|
],
|
|
48
56
|
localOnly: true,
|
|
49
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Local-only change log used for peer sync.
|
|
60
|
+
*
|
|
61
|
+
* Owns two {@link HybridLogicalClock} instances for this database:
|
|
62
|
+
* - {@link clock} stamps `createdAt` / `supersededAt` (cross-device LWW)
|
|
63
|
+
* - `appliedClock` stamps `appliedAt` at insert time (per-device sync watermark)
|
|
64
|
+
*/
|
|
50
65
|
class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
51
66
|
dataChangedEmitter;
|
|
52
67
|
dataChanged;
|
|
53
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Hybrid logical clock for author write times (`createdAt`, `supersededAt`).
|
|
70
|
+
* Observes accepted incoming `createdAt` values so later local writes order after them.
|
|
71
|
+
*/
|
|
72
|
+
clock;
|
|
73
|
+
appliedClock;
|
|
74
|
+
clockInit;
|
|
75
|
+
/**
|
|
76
|
+
* @param db - SQLite database that stores `ChangeTrackingV2`
|
|
77
|
+
* @param now - Optional physical-time source shared by both clocks (defaults to {@link getTimestamp})
|
|
78
|
+
*/
|
|
79
|
+
constructor({ db, now }) {
|
|
54
80
|
super(db, changeTrackingV2TableMetaData, exports.changeRecordSchema);
|
|
81
|
+
const physicalNow = now ?? utils_1.getTimestamp;
|
|
82
|
+
this.clock = new hlc_1.HybridLogicalClock({ now: physicalNow });
|
|
83
|
+
this.appliedClock = new hlc_1.HybridLogicalClock({
|
|
84
|
+
now: physicalNow,
|
|
85
|
+
maxSkewMs: Number.POSITIVE_INFINITY,
|
|
86
|
+
});
|
|
55
87
|
const tableName = changeTrackingV2TableMetaData.name;
|
|
56
88
|
this.dataChangedEmitter = new events_1.Emitter(`${tableName}_DataChanged_${(0, utils_1.newid)()}`);
|
|
57
89
|
this.dataChanged = this.dataChangedEmitter.event;
|
|
58
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Ensures the table exists, seeds both clocks from durable history, and
|
|
93
|
+
* rewrites any far-future `createdAt` rows this device still owns.
|
|
94
|
+
*
|
|
95
|
+
* @param force - Re-run table migration and clock seeding
|
|
96
|
+
*/
|
|
97
|
+
async initTable(force = false) {
|
|
98
|
+
const created = await super.initTable(force);
|
|
99
|
+
if (!this.clockInit || force) {
|
|
100
|
+
this.clockInit = this.seedClocksAndRepair();
|
|
101
|
+
}
|
|
102
|
+
await this.clockInit;
|
|
103
|
+
return created;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Drop the change-tracking table and clear memoized clock seeding so the next
|
|
107
|
+
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
108
|
+
*/
|
|
109
|
+
async dropTableIfExists() {
|
|
110
|
+
await super.dropTableIfExists();
|
|
111
|
+
this.clockInit = undefined;
|
|
112
|
+
}
|
|
113
|
+
async seedClocksAndRepair() {
|
|
114
|
+
const physicalNow = this.clock.now();
|
|
115
|
+
const cutoff = physicalNow + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
116
|
+
const bounds = await this.db.get(`SELECT
|
|
117
|
+
MAX(CASE WHEN createdAt <= ? THEN createdAt END) as maxCreatedAt,
|
|
118
|
+
MAX(appliedAt) as maxAppliedAt
|
|
119
|
+
FROM "${this.tableName}"`, [cutoff]);
|
|
120
|
+
if (bounds?.maxAppliedAt != null) {
|
|
121
|
+
this.appliedClock.seed(Number(bounds.maxAppliedAt));
|
|
122
|
+
}
|
|
123
|
+
if (bounds?.maxCreatedAt != null) {
|
|
124
|
+
this.clock.seed(Number(bounds.maxCreatedAt));
|
|
125
|
+
}
|
|
126
|
+
await this.repairFutureTimestamps();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Rewrite local rows whose `createdAt` is beyond the change-timestamp bound.
|
|
130
|
+
* `createdAt` is restamped from {@link clock} (near wall time) and `appliedAt`
|
|
131
|
+
* from the unbounded apply clock so peers whose watermark already passed the
|
|
132
|
+
* old value will pull the repaired rows.
|
|
133
|
+
*
|
|
134
|
+
* @returns Number of rows rewritten
|
|
135
|
+
*/
|
|
136
|
+
async repairFutureTimestamps() {
|
|
137
|
+
await super.initTable();
|
|
138
|
+
const cutoff = this.clock.now() + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
139
|
+
const inBoundMax = await this.db.get(`SELECT MAX(createdAt) as maxCreatedAt FROM "${this.tableName}" WHERE createdAt <= ?`, [cutoff]);
|
|
140
|
+
if (inBoundMax?.maxCreatedAt != null) {
|
|
141
|
+
this.clock.seed(Number(inBoundMax.maxCreatedAt));
|
|
142
|
+
}
|
|
143
|
+
const rows = await this.db.all(`SELECT changeId FROM "${this.tableName}" WHERE createdAt > ? ORDER BY createdAt ASC`, [cutoff]);
|
|
144
|
+
if (rows.length === 0) {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
const createdAt = this.clock.tick();
|
|
149
|
+
const appliedAt = this.appliedClock.tick();
|
|
150
|
+
await this.db.exec(`UPDATE "${this.tableName}" SET createdAt = ?, appliedAt = ? WHERE changeId = ?`, [createdAt, appliedAt, row.changeId]);
|
|
151
|
+
}
|
|
152
|
+
console.log(`Repaired ${rows.length} change tracking row(s) with timestamps too far in the future`);
|
|
153
|
+
return rows.length;
|
|
154
|
+
}
|
|
59
155
|
/**
|
|
60
156
|
* Efficiently find which recordIds have been deleted.
|
|
61
157
|
*
|
|
@@ -144,6 +240,10 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
144
240
|
`, changeIds);
|
|
145
241
|
return missingChangeIds.map((d) => d.changeId);
|
|
146
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Mark every still-active change for `recordId` whose `createdAt` is older
|
|
245
|
+
* than `supersededAt`. Used after a root snapshot, delete, or restore.
|
|
246
|
+
*/
|
|
147
247
|
async markAllPriorChangesSuperseded(tableName, recordId, supersededAt) {
|
|
148
248
|
await this.db.exec(`
|
|
149
249
|
UPDATE "${this.tableName}"
|
|
@@ -193,9 +293,12 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
193
293
|
return record ? this.deserializeValue(record) : undefined;
|
|
194
294
|
}
|
|
195
295
|
/**
|
|
196
|
-
*
|
|
296
|
+
* Insert a change row, assigning `appliedAt` from the local apply clock
|
|
297
|
+
* immediately before the SQL statement so watermarks stay monotonic.
|
|
197
298
|
*/
|
|
198
299
|
async insert(record) {
|
|
300
|
+
await this.initTable();
|
|
301
|
+
record.appliedAt = this.appliedClock.tick();
|
|
199
302
|
if (record.value !== undefined) {
|
|
200
303
|
record.value = (0, serial_json_1.toJSONString)(record.value);
|
|
201
304
|
}
|
|
@@ -216,8 +319,10 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
216
319
|
/**
|
|
217
320
|
* Synchronous insert for use within transactions.
|
|
218
321
|
* Table must be initialized before calling this method.
|
|
322
|
+
* Assigns `appliedAt` from the local apply clock immediately before the SQL statement.
|
|
219
323
|
*/
|
|
220
324
|
insertSync(record) {
|
|
325
|
+
record.appliedAt = this.appliedClock.tick();
|
|
221
326
|
if (record.value !== undefined) {
|
|
222
327
|
record = { ...record, value: (0, serial_json_1.toJSONString)(record.value) };
|
|
223
328
|
}
|
|
@@ -4,9 +4,9 @@ exports.Connection = void 0;
|
|
|
4
4
|
exports.normalizeAddress = normalizeAddress;
|
|
5
5
|
const data_1 = require("../data");
|
|
6
6
|
const keys_1 = require("../keys");
|
|
7
|
+
const peer_device_1 = require("../types/peer-device");
|
|
7
8
|
const utils_1 = require("../utils");
|
|
8
9
|
const socket_type_1 = require("./socket.type");
|
|
9
|
-
const HANDSHAKE_TIMESTAMP_TOLERANCE_MS = 300_000; // 5 minutes
|
|
10
10
|
/** Authenticated RPC connection between two Peers devices. */
|
|
11
11
|
class Connection {
|
|
12
12
|
localDevice;
|
|
@@ -26,7 +26,7 @@ class Connection {
|
|
|
26
26
|
onHandshakeComplete;
|
|
27
27
|
timeoutMs = 60_000; // 60 seconds
|
|
28
28
|
maxChunkSize = socket_type_1.DEFAULT_MAX_CHUNK_SIZE;
|
|
29
|
-
handshakeTimestampToleranceMs =
|
|
29
|
+
handshakeTimestampToleranceMs = peer_device_1.PeerDeviceConsts.CLOCK_SKEW_TOLERANCE_MS;
|
|
30
30
|
_connectionAddress;
|
|
31
31
|
get connectionAddress() {
|
|
32
32
|
return this._connectionAddress;
|
package/dist/hlc.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link HybridLogicalClock}.
|
|
3
|
+
*/
|
|
4
|
+
export interface IHybridLogicalClockOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Physical-time source in milliseconds. Defaults to {@link getTimestamp}
|
|
7
|
+
* so HLC ticks stay on the same clock as existing change-log rows and tests.
|
|
8
|
+
* (`Date.now` and `performance.timeOrigin + performance.now` drift apart
|
|
9
|
+
* in long-running processes.) Injected in tests to simulate clock skew.
|
|
10
|
+
*/
|
|
11
|
+
now?: () => number;
|
|
12
|
+
/**
|
|
13
|
+
* Incoming timestamps more than this many ms ahead of {@link HybridLogicalClock.now}
|
|
14
|
+
* are ignored by {@link HybridLogicalClock.observe}. Defaults to
|
|
15
|
+
* {@link PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS}.
|
|
16
|
+
*/
|
|
17
|
+
maxSkewMs?: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Compact hybrid logical clock stored as a single millisecond number.
|
|
21
|
+
*
|
|
22
|
+
* `tick()` is `max(now(), last + 1)` so local timestamps stay close to wall time
|
|
23
|
+
* while remaining strictly monotonic even when the system clock jumps backward.
|
|
24
|
+
* `observe(ts)` raises `last` to a received timestamp so a later local tick
|
|
25
|
+
* always orders after a change this device has already seen.
|
|
26
|
+
*/
|
|
27
|
+
export declare class HybridLogicalClock {
|
|
28
|
+
private last;
|
|
29
|
+
private readonly getNow;
|
|
30
|
+
private readonly maxSkewMs;
|
|
31
|
+
/**
|
|
32
|
+
* @param opts - Optional physical-time source and observe-skew bound
|
|
33
|
+
*/
|
|
34
|
+
constructor(opts?: IHybridLogicalClockOptions);
|
|
35
|
+
/**
|
|
36
|
+
* Current physical time from the injected `now` function. Does not advance the clock.
|
|
37
|
+
*/
|
|
38
|
+
now(): number;
|
|
39
|
+
/**
|
|
40
|
+
* Last assigned or observed value, or `0` before the first tick/seed/observe.
|
|
41
|
+
*/
|
|
42
|
+
get value(): number;
|
|
43
|
+
/**
|
|
44
|
+
* Assign the next timestamp: `last = max(now(), last + 1)`.
|
|
45
|
+
*
|
|
46
|
+
* @returns The newly assigned timestamp
|
|
47
|
+
*/
|
|
48
|
+
tick(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Incorporate a timestamp received from another device.
|
|
51
|
+
* Values that are not finite, or that exceed `now() + maxSkewMs`, are ignored
|
|
52
|
+
* so one misconfigured peer cannot push this clock into the far future.
|
|
53
|
+
*
|
|
54
|
+
* @param ts - Received timestamp
|
|
55
|
+
* @returns `true` if the clock absorbed `ts`
|
|
56
|
+
*/
|
|
57
|
+
observe(ts: number): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Raise `last` to at least `ts` without consulting the skew bound.
|
|
60
|
+
* Used when seeding from durable local history on startup.
|
|
61
|
+
*
|
|
62
|
+
* @param ts - Known-good timestamp from local storage
|
|
63
|
+
*/
|
|
64
|
+
seed(ts: number): void;
|
|
65
|
+
}
|
package/dist/hlc.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HybridLogicalClock = void 0;
|
|
4
|
+
const peer_device_1 = require("./types/peer-device");
|
|
5
|
+
const utils_1 = require("./utils");
|
|
6
|
+
/**
|
|
7
|
+
* Compact hybrid logical clock stored as a single millisecond number.
|
|
8
|
+
*
|
|
9
|
+
* `tick()` is `max(now(), last + 1)` so local timestamps stay close to wall time
|
|
10
|
+
* while remaining strictly monotonic even when the system clock jumps backward.
|
|
11
|
+
* `observe(ts)` raises `last` to a received timestamp so a later local tick
|
|
12
|
+
* always orders after a change this device has already seen.
|
|
13
|
+
*/
|
|
14
|
+
class HybridLogicalClock {
|
|
15
|
+
last = 0;
|
|
16
|
+
getNow;
|
|
17
|
+
maxSkewMs;
|
|
18
|
+
/**
|
|
19
|
+
* @param opts - Optional physical-time source and observe-skew bound
|
|
20
|
+
*/
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
this.getNow = opts?.now ?? utils_1.getTimestamp;
|
|
23
|
+
this.maxSkewMs = opts?.maxSkewMs ?? peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Current physical time from the injected `now` function. Does not advance the clock.
|
|
27
|
+
*/
|
|
28
|
+
now() {
|
|
29
|
+
return this.getNow();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Last assigned or observed value, or `0` before the first tick/seed/observe.
|
|
33
|
+
*/
|
|
34
|
+
get value() {
|
|
35
|
+
return this.last;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Assign the next timestamp: `last = max(now(), last + 1)`.
|
|
39
|
+
*
|
|
40
|
+
* @returns The newly assigned timestamp
|
|
41
|
+
*/
|
|
42
|
+
tick() {
|
|
43
|
+
this.last = Math.max(this.getNow(), this.last + 1);
|
|
44
|
+
return this.last;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Incorporate a timestamp received from another device.
|
|
48
|
+
* Values that are not finite, or that exceed `now() + maxSkewMs`, are ignored
|
|
49
|
+
* so one misconfigured peer cannot push this clock into the far future.
|
|
50
|
+
*
|
|
51
|
+
* @param ts - Received timestamp
|
|
52
|
+
* @returns `true` if the clock absorbed `ts`
|
|
53
|
+
*/
|
|
54
|
+
observe(ts) {
|
|
55
|
+
if (!Number.isFinite(ts)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (ts > this.getNow() + this.maxSkewMs) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
this.last = Math.max(this.last, ts);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Raise `last` to at least `ts` without consulting the skew bound.
|
|
66
|
+
* Used when seeding from durable local history on startup.
|
|
67
|
+
*
|
|
68
|
+
* @param ts - Known-good timestamp from local storage
|
|
69
|
+
*/
|
|
70
|
+
seed(ts) {
|
|
71
|
+
if (!Number.isFinite(ts)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.last = Math.max(this.last, ts);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
exports.HybridLogicalClock = HybridLogicalClock;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/hlc.test.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const hlc_1 = require("./hlc");
|
|
4
|
+
const peer_device_1 = require("./types/peer-device");
|
|
5
|
+
describe("HybridLogicalClock", () => {
|
|
6
|
+
it("is monotonic when now goes backward", () => {
|
|
7
|
+
let t = 1000;
|
|
8
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => t });
|
|
9
|
+
const first = clock.tick();
|
|
10
|
+
t = 100;
|
|
11
|
+
const second = clock.tick();
|
|
12
|
+
expect(second).toBe(first + 1);
|
|
13
|
+
});
|
|
14
|
+
it("advances by 1 on the same millisecond", () => {
|
|
15
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 5000 });
|
|
16
|
+
expect(clock.tick()).toBe(5000);
|
|
17
|
+
expect(clock.tick()).toBe(5001);
|
|
18
|
+
expect(clock.tick()).toBe(5002);
|
|
19
|
+
});
|
|
20
|
+
it("observe advances last so the next tick is after the observed time", () => {
|
|
21
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 1000 });
|
|
22
|
+
expect(clock.observe(2500)).toBe(true);
|
|
23
|
+
expect(clock.tick()).toBe(2501);
|
|
24
|
+
});
|
|
25
|
+
it("observe ignores timestamps beyond the skew bound", () => {
|
|
26
|
+
const now = 10_000;
|
|
27
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => now });
|
|
28
|
+
const future = now + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS + 1;
|
|
29
|
+
expect(clock.observe(future)).toBe(false);
|
|
30
|
+
expect(clock.tick()).toBe(now);
|
|
31
|
+
});
|
|
32
|
+
it("seed raises last without going backward", () => {
|
|
33
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 1000 });
|
|
34
|
+
clock.seed(2000);
|
|
35
|
+
clock.seed(1500);
|
|
36
|
+
expect(clock.tick()).toBe(2001);
|
|
37
|
+
});
|
|
38
|
+
it("now peeks physical time without advancing", () => {
|
|
39
|
+
const clock = new hlc_1.HybridLogicalClock({ now: () => 42 });
|
|
40
|
+
expect(clock.now()).toBe(42);
|
|
41
|
+
expect(clock.now()).toBe(42);
|
|
42
|
+
expect(clock.value).toBe(0);
|
|
43
|
+
expect(clock.tick()).toBe(42);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -37,6 +37,7 @@ __exportStar(require("./device/tx-encoding"), exports);
|
|
|
37
37
|
__exportStar(require("./device-pairing"), exports);
|
|
38
38
|
__exportStar(require("./events"), exports);
|
|
39
39
|
__exportStar(require("./group-invite"), exports);
|
|
40
|
+
__exportStar(require("./hlc"), exports);
|
|
40
41
|
__exportStar(require("./keys"), exports);
|
|
41
42
|
__exportStar(require("./logging"), exports);
|
|
42
43
|
__exportStar(require("./mentions"), exports);
|
|
@@ -81,6 +81,14 @@ export declare const PeerDeviceConsts: Readonly<{
|
|
|
81
81
|
readonly TIMEOUT_MIN: 3000;
|
|
82
82
|
readonly TIMEOUT_MAX: 30000;
|
|
83
83
|
readonly NETWORK_INFO_CACHE_TIME: 1000;
|
|
84
|
+
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
85
|
+
readonly CLOCK_SKEW_TOLERANCE_MS: 300000;
|
|
86
|
+
/**
|
|
87
|
+
* Incoming change `createdAt` values more than this far ahead of local now
|
|
88
|
+
* are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
|
|
89
|
+
* skew does not reject legitimate changes.
|
|
90
|
+
*/
|
|
91
|
+
readonly CHANGE_TIMESTAMP_TOLERANCE_MS: 600000;
|
|
84
92
|
}>;
|
|
85
93
|
/** Availability and queue information for a requested file chunk. */
|
|
86
94
|
export type IFileChunkInfo = {
|
|
@@ -13,4 +13,12 @@ exports.PeerDeviceConsts = Object.freeze({
|
|
|
13
13
|
TIMEOUT_MIN: 3_000,
|
|
14
14
|
TIMEOUT_MAX: 30_000,
|
|
15
15
|
NETWORK_INFO_CACHE_TIME: 1000,
|
|
16
|
+
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
17
|
+
CLOCK_SKEW_TOLERANCE_MS: 300_000,
|
|
18
|
+
/**
|
|
19
|
+
* Incoming change `createdAt` values more than this far ahead of local now
|
|
20
|
+
* are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
|
|
21
|
+
* skew does not reject legitimate changes.
|
|
22
|
+
*/
|
|
23
|
+
CHANGE_TIMESTAMP_TOLERANCE_MS: 600_000,
|
|
16
24
|
});
|