@peers-app/peers-sdk 0.25.5 → 0.25.6
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 +9 -9
- package/dist/data/change-tracking.js +13 -32
- package/dist/types/peer-device.d.ts +5 -3
- package/dist/types/peer-device.js +5 -3
- package/dist/utils.d.ts +20 -2
- package/dist/utils.js +35 -6
- package/dist/utils.test.js +29 -0
- package/package.json +1 -1
|
@@ -77,8 +77,7 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
77
77
|
now?: () => number;
|
|
78
78
|
});
|
|
79
79
|
/**
|
|
80
|
-
* Ensures the table exists
|
|
81
|
-
* rewrites any far-future `createdAt` rows this device still owns.
|
|
80
|
+
* Ensures the table exists and seeds both clocks from durable history.
|
|
82
81
|
*
|
|
83
82
|
* @param force - Re-run table migration and clock seeding
|
|
84
83
|
*/
|
|
@@ -88,16 +87,17 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
|
|
|
88
87
|
* {@link initTable} reseeds from an empty (or newly created) table.
|
|
89
88
|
*/
|
|
90
89
|
dropTableIfExists(): Promise<void>;
|
|
91
|
-
private seedClocksAndRepair;
|
|
92
90
|
/**
|
|
93
|
-
*
|
|
94
|
-
* `createdAt` is
|
|
95
|
-
*
|
|
96
|
-
* old value will pull the repaired rows.
|
|
91
|
+
* Seed the apply clock from `MAX(appliedAt)` and the write clock from the largest
|
|
92
|
+
* `createdAt` that is not far in the future, so a stored row with a bad timestamp
|
|
93
|
+
* cannot push every later local write into the future.
|
|
97
94
|
*
|
|
98
|
-
*
|
|
95
|
+
* Rows are never rewritten here. A change's `createdAt` and `changeId` are its
|
|
96
|
+
* identity across devices: peers that already hold the row would ignore a
|
|
97
|
+
* re-sent copy, so restamping locally would make this device order the change
|
|
98
|
+
* differently from everyone else.
|
|
99
99
|
*/
|
|
100
|
-
|
|
100
|
+
private seedClocks;
|
|
101
101
|
/**
|
|
102
102
|
* Efficiently find which recordIds have been deleted.
|
|
103
103
|
*
|
|
@@ -89,15 +89,14 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
89
89
|
this.dataChanged = this.dataChangedEmitter.event;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* Ensures the table exists
|
|
93
|
-
* rewrites any far-future `createdAt` rows this device still owns.
|
|
92
|
+
* Ensures the table exists and seeds both clocks from durable history.
|
|
94
93
|
*
|
|
95
94
|
* @param force - Re-run table migration and clock seeding
|
|
96
95
|
*/
|
|
97
96
|
async initTable(force = false) {
|
|
98
97
|
const created = await super.initTable(force);
|
|
99
98
|
if (!this.clockInit || force) {
|
|
100
|
-
this.clockInit = this.
|
|
99
|
+
this.clockInit = this.seedClocks();
|
|
101
100
|
}
|
|
102
101
|
await this.clockInit;
|
|
103
102
|
return created;
|
|
@@ -110,7 +109,17 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
110
109
|
await super.dropTableIfExists();
|
|
111
110
|
this.clockInit = undefined;
|
|
112
111
|
}
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Seed the apply clock from `MAX(appliedAt)` and the write clock from the largest
|
|
114
|
+
* `createdAt` that is not far in the future, so a stored row with a bad timestamp
|
|
115
|
+
* cannot push every later local write into the future.
|
|
116
|
+
*
|
|
117
|
+
* Rows are never rewritten here. A change's `createdAt` and `changeId` are its
|
|
118
|
+
* identity across devices: peers that already hold the row would ignore a
|
|
119
|
+
* re-sent copy, so restamping locally would make this device order the change
|
|
120
|
+
* differently from everyone else.
|
|
121
|
+
*/
|
|
122
|
+
async seedClocks() {
|
|
114
123
|
const physicalNow = this.clock.now();
|
|
115
124
|
const cutoff = physicalNow + peer_device_1.PeerDeviceConsts.CHANGE_TIMESTAMP_TOLERANCE_MS;
|
|
116
125
|
const bounds = await this.db.get(`SELECT
|
|
@@ -123,34 +132,6 @@ class ChangeTrackingTable extends orm_1.SQLDataSource {
|
|
|
123
132
|
if (bounds?.maxCreatedAt != null) {
|
|
124
133
|
this.clock.seed(Number(bounds.maxCreatedAt));
|
|
125
134
|
}
|
|
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
135
|
}
|
|
155
136
|
/**
|
|
156
137
|
* Efficiently find which recordIds have been deleted.
|
|
@@ -98,9 +98,11 @@ export declare const PeerDeviceConsts: Readonly<{
|
|
|
98
98
|
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
99
99
|
readonly CLOCK_SKEW_TOLERANCE_MS: 300000;
|
|
100
100
|
/**
|
|
101
|
-
* Incoming change `createdAt` values more than this far ahead of local now
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* Incoming change `createdAt` values more than this far ahead of local now are
|
|
102
|
+
* excluded from active history and do not advance the local hybrid logical
|
|
103
|
+
* clock. The receiver must also leave the source sync watermark unchanged so
|
|
104
|
+
* the row is offered again after its clock is corrected. 2× the handshake
|
|
105
|
+
* bound allows for multi-hop HLC lead plus permitted direct-peer skew.
|
|
104
106
|
*/
|
|
105
107
|
readonly CHANGE_TIMESTAMP_TOLERANCE_MS: 600000;
|
|
106
108
|
}>;
|
|
@@ -30,9 +30,11 @@ exports.PeerDeviceConsts = Object.freeze({
|
|
|
30
30
|
/** Handshake rejects peers whose wall clock differs by more than this. */
|
|
31
31
|
CLOCK_SKEW_TOLERANCE_MS: 300_000,
|
|
32
32
|
/**
|
|
33
|
-
* Incoming change `createdAt` values more than this far ahead of local now
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Incoming change `createdAt` values more than this far ahead of local now are
|
|
34
|
+
* excluded from active history and do not advance the local hybrid logical
|
|
35
|
+
* clock. The receiver must also leave the source sync watermark unchanged so
|
|
36
|
+
* the row is offered again after its clock is corrected. 2× the handshake
|
|
37
|
+
* bound allows for multi-hop HLC lead plus permitted direct-peer skew.
|
|
36
38
|
*/
|
|
37
39
|
CHANGE_TIMESTAMP_TOLERANCE_MS: 600_000,
|
|
38
40
|
});
|
package/dist/utils.d.ts
CHANGED
|
@@ -89,8 +89,26 @@ export declare function memoizePromise<F extends (...args: any[]) => Promise<any
|
|
|
89
89
|
*/
|
|
90
90
|
export declare function debounceByArgs<F extends (...args: any[]) => any>(fn: F, wait?: number): F;
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
92
|
+
* If the high-resolution estimate drifts from `Date.now()` by more than this many
|
|
93
|
+
* milliseconds, {@link getTimestamp} re-anchors to the wall clock.
|
|
94
|
+
*/
|
|
95
|
+
export declare const TIMESTAMP_REANCHOR_THRESHOLD_MS = 1000;
|
|
96
|
+
/**
|
|
97
|
+
* Wall-clock time in milliseconds with sub-millisecond resolution where available.
|
|
98
|
+
*
|
|
99
|
+
* Returns `Date.now()` when the `performance` API is missing. Otherwise the value
|
|
100
|
+
* is `performance.now()` offset by an anchor taken from `Date.now()`, which is
|
|
101
|
+
* re-taken whenever the two disagree by more than
|
|
102
|
+
* {@link TIMESTAMP_REANCHOR_THRESHOLD_MS}. The anchor matters: `performance.now()`
|
|
103
|
+
* is a monotonic clock that on iOS Safari (and some other hosts) does **not**
|
|
104
|
+
* advance while the page or device is suspended, so a naive
|
|
105
|
+
* `timeOrigin + now()` falls behind the wall clock by the length of every sleep.
|
|
106
|
+
* A change-tracking clock built on that value then sees every fresh remote
|
|
107
|
+
* change as "far in the future". This function tracks `Date.now()` instead and
|
|
108
|
+
* only uses `performance.now()` for the fractional part and short-term smoothness.
|
|
109
|
+
*
|
|
110
|
+
* Values may step backward when the system clock is adjusted; callers that need
|
|
111
|
+
* monotonicity (e.g. `HybridLogicalClock`) handle that themselves.
|
|
94
112
|
*/
|
|
95
113
|
export declare function getTimestamp(): number;
|
|
96
114
|
/**
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.moneyFormatter = exports.MAX_ID = exports.MIN_ID = void 0;
|
|
3
|
+
exports.TIMESTAMP_REANCHOR_THRESHOLD_MS = exports.moneyFormatter = exports.MAX_ID = exports.MIN_ID = void 0;
|
|
4
4
|
exports.cryptoRandomString = cryptoRandomString;
|
|
5
5
|
exports.newid = newid;
|
|
6
6
|
exports.isid = isid;
|
|
@@ -276,16 +276,45 @@ function debounceByArgs(fn, wait = 300) {
|
|
|
276
276
|
return debounced;
|
|
277
277
|
}
|
|
278
278
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
279
|
+
* If the high-resolution estimate drifts from `Date.now()` by more than this many
|
|
280
|
+
* milliseconds, {@link getTimestamp} re-anchors to the wall clock.
|
|
281
|
+
*/
|
|
282
|
+
exports.TIMESTAMP_REANCHOR_THRESHOLD_MS = 1_000;
|
|
283
|
+
let perfAnchorWall;
|
|
284
|
+
let perfAnchorHighRes;
|
|
285
|
+
/**
|
|
286
|
+
* Wall-clock time in milliseconds with sub-millisecond resolution where available.
|
|
287
|
+
*
|
|
288
|
+
* Returns `Date.now()` when the `performance` API is missing. Otherwise the value
|
|
289
|
+
* is `performance.now()` offset by an anchor taken from `Date.now()`, which is
|
|
290
|
+
* re-taken whenever the two disagree by more than
|
|
291
|
+
* {@link TIMESTAMP_REANCHOR_THRESHOLD_MS}. The anchor matters: `performance.now()`
|
|
292
|
+
* is a monotonic clock that on iOS Safari (and some other hosts) does **not**
|
|
293
|
+
* advance while the page or device is suspended, so a naive
|
|
294
|
+
* `timeOrigin + now()` falls behind the wall clock by the length of every sleep.
|
|
295
|
+
* A change-tracking clock built on that value then sees every fresh remote
|
|
296
|
+
* change as "far in the future". This function tracks `Date.now()` instead and
|
|
297
|
+
* only uses `performance.now()` for the fractional part and short-term smoothness.
|
|
298
|
+
*
|
|
299
|
+
* Values may step backward when the system clock is adjusted; callers that need
|
|
300
|
+
* monotonicity (e.g. `HybridLogicalClock`) handle that themselves.
|
|
281
301
|
*/
|
|
282
302
|
function getTimestamp() {
|
|
303
|
+
const wall = Date.now();
|
|
283
304
|
// check if in node.js environment
|
|
284
|
-
if (typeof performance === "undefined" ||
|
|
305
|
+
if (typeof performance === "undefined" || typeof performance.now !== "function") {
|
|
285
306
|
// Fallback for environments without performance API
|
|
286
|
-
return
|
|
307
|
+
return wall;
|
|
308
|
+
}
|
|
309
|
+
const highRes = performance.now();
|
|
310
|
+
if (perfAnchorWall === undefined ||
|
|
311
|
+
perfAnchorHighRes === undefined ||
|
|
312
|
+
Math.abs(perfAnchorWall + (highRes - perfAnchorHighRes) - wall) >
|
|
313
|
+
exports.TIMESTAMP_REANCHOR_THRESHOLD_MS) {
|
|
314
|
+
perfAnchorWall = wall;
|
|
315
|
+
perfAnchorHighRes = highRes;
|
|
287
316
|
}
|
|
288
|
-
return
|
|
317
|
+
return perfAnchorWall + (highRes - perfAnchorHighRes);
|
|
289
318
|
}
|
|
290
319
|
/**
|
|
291
320
|
* Runs `fn` with adaptive timeout from {@link IDeviceConnection.latencyMs}, retrying on error or until the op finishes.
|
package/dist/utils.test.js
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const lodash_1 = require("lodash");
|
|
4
4
|
const utils_1 = require("./utils");
|
|
5
|
+
describe("getTimestamp", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
jest.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
it("tracks the wall clock while the high-resolution clock is frozen (suspended tab)", () => {
|
|
10
|
+
// performance.now() stops advancing on iOS while the page is suspended; Date.now() does not.
|
|
11
|
+
const wallStart = 1_790_000_000_000;
|
|
12
|
+
let wall = wallStart;
|
|
13
|
+
const frozenHighRes = 12_345.678;
|
|
14
|
+
jest.spyOn(Date, "now").mockImplementation(() => wall);
|
|
15
|
+
jest.spyOn(performance, "now").mockImplementation(() => frozenHighRes);
|
|
16
|
+
const before = (0, utils_1.getTimestamp)();
|
|
17
|
+
expect(Math.abs(before - wallStart)).toBeLessThanOrEqual(utils_1.TIMESTAMP_REANCHOR_THRESHOLD_MS);
|
|
18
|
+
wall += 15 * 60 * 1000; // device slept for 15 minutes
|
|
19
|
+
const after = (0, utils_1.getTimestamp)();
|
|
20
|
+
expect(Math.abs(after - wall)).toBeLessThanOrEqual(utils_1.TIMESTAMP_REANCHOR_THRESHOLD_MS);
|
|
21
|
+
expect(after - before).toBeGreaterThan(14 * 60 * 1000);
|
|
22
|
+
});
|
|
23
|
+
it("keeps sub-millisecond resolution between re-anchors", () => {
|
|
24
|
+
const wall = 1_790_000_000_000;
|
|
25
|
+
let highRes = 1_000;
|
|
26
|
+
jest.spyOn(Date, "now").mockImplementation(() => wall + Math.floor(highRes - 1_000));
|
|
27
|
+
jest.spyOn(performance, "now").mockImplementation(() => highRes);
|
|
28
|
+
const first = (0, utils_1.getTimestamp)();
|
|
29
|
+
highRes += 0.25;
|
|
30
|
+
const second = (0, utils_1.getTimestamp)();
|
|
31
|
+
expect(second - first).toBeCloseTo(0.25, 5);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
5
34
|
describe("newid", () => {
|
|
6
35
|
it("should return a valid id", async () => {
|
|
7
36
|
const id = (0, utils_1.newid)();
|