@peers-app/peers-sdk 0.25.4 → 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.
@@ -77,8 +77,7 @@ export declare class ChangeTrackingTable extends SQLDataSource<IChangeRecord> {
77
77
  now?: () => number;
78
78
  });
79
79
  /**
80
- * Ensures the table exists, seeds both clocks from durable history, and
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
- * 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.
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
- * @returns Number of rows rewritten
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
- repairFutureTimestamps(): Promise<number>;
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, seeds both clocks from durable history, and
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.seedClocksAndRepair();
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
- async seedClocksAndRepair() {
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
- * are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
103
- * skew does not reject legitimate changes.
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
- * are skipped. 2× the handshake bound so multi-hop HLC lead plus allowed
35
- * skew does not reject legitimate changes.
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
- * Monotonic-ish high-resolution time when `performance` exists; otherwise `Date.now()`.
93
- * Uses `timeOrigin + now()` in browsers; suitable for relative timing, not wall-clock.
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
- * Monotonic-ish high-resolution time when `performance` exists; otherwise `Date.now()`.
280
- * Uses `timeOrigin + now()` in browsers; suitable for relative timing, not wall-clock.
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" || !performance.timeOrigin) {
305
+ if (typeof performance === "undefined" || typeof performance.now !== "function") {
285
306
  // Fallback for environments without performance API
286
- return Date.now();
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 performance.timeOrigin + performance.now();
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.
@@ -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)();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.25.4",
3
+ "version": "0.25.6",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"