@baukit/sync-client 0.7.1 → 0.7.3

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/src/error.ts ADDED
@@ -0,0 +1,116 @@
1
+ /** Machine-readable failure metadata. Products own any message shown to users. */
2
+ export type SyncFailure =
3
+ | { readonly kind: 'auth' }
4
+ | { readonly kind: 'partition_mismatch' }
5
+ | { readonly kind: 'rate_limited'; readonly retryAt: string }
6
+ | { readonly kind: 'network' }
7
+ | { readonly kind: 'server' }
8
+ | { readonly kind: 'payload_compatibility' }
9
+ | { readonly kind: 'local_apply' };
10
+
11
+ /** A sync request failed. `retryable` decides whether a later attempt may win. */
12
+ export class SyncTransportError extends Error {
13
+ constructor(
14
+ message: string,
15
+ readonly retryable: boolean,
16
+ override readonly cause?: unknown,
17
+ ) {
18
+ super(message);
19
+ this.name = 'SyncTransportError';
20
+ }
21
+ }
22
+
23
+ /** Credentials were rejected. Never retryable without re-authentication. */
24
+ export class SyncAuthError extends SyncTransportError {
25
+ readonly kind = 'auth';
26
+
27
+ constructor(message: string, cause?: unknown) {
28
+ super(message, false, cause);
29
+ this.name = 'SyncAuthError';
30
+ }
31
+ }
32
+
33
+ /** The request did not reach a usable HTTP response. */
34
+ export class SyncNetworkError extends SyncTransportError {
35
+ readonly kind = 'network';
36
+
37
+ constructor(message: string, cause?: unknown) {
38
+ super(message, true, cause);
39
+ this.name = 'SyncNetworkError';
40
+ }
41
+ }
42
+
43
+ /** The server returned an HTTP failure other than authentication or rate limiting. */
44
+ export class SyncServerError extends SyncTransportError {
45
+ readonly kind = 'server';
46
+
47
+ constructor(message: string, retryable: boolean, cause?: unknown) {
48
+ super(message, retryable, cause);
49
+ this.name = 'SyncServerError';
50
+ }
51
+ }
52
+
53
+ /** HTTP 429 with its raw `Retry-After` value and resolved retry time. */
54
+ export class SyncRateLimitError extends SyncTransportError {
55
+ readonly kind = 'rate_limited';
56
+
57
+ constructor(
58
+ message: string,
59
+ readonly retryAt: string,
60
+ readonly retryAfter: string | null,
61
+ cause?: unknown,
62
+ ) {
63
+ super(message, true, cause);
64
+ this.name = 'SyncRateLimitError';
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The server rejected the caller's local-data partition, so the local database
70
+ * belongs to an erased or replaced owner and must not be reused.
71
+ */
72
+ export class SyncPartitionMismatchError extends SyncTransportError {
73
+ readonly kind = 'partition_mismatch';
74
+
75
+ constructor(message: string, cause?: unknown) {
76
+ super(message, false, cause);
77
+ this.name = 'SyncPartitionMismatchError';
78
+ }
79
+ }
80
+
81
+ /** A decoded response violates the sync payload contract. */
82
+ export class SyncPayloadCompatibilityError extends SyncTransportError {
83
+ readonly kind = 'payload_compatibility';
84
+
85
+ constructor(message: string, cause?: unknown) {
86
+ super(message, false, cause);
87
+ this.name = 'SyncPayloadCompatibilityError';
88
+ }
89
+ }
90
+
91
+ /** Remote data or a cursor could not be committed to local storage. */
92
+ export class SyncLocalApplyError extends Error {
93
+ readonly kind = 'local_apply';
94
+
95
+ constructor(message: string, cause?: unknown) {
96
+ super(message, { cause });
97
+ this.name = 'SyncLocalApplyError';
98
+ }
99
+ }
100
+
101
+ /** Maps package errors onto store failure metadata. */
102
+ export function syncFailureFromError(error: unknown): SyncFailure {
103
+ if (error instanceof SyncRateLimitError) {
104
+ return { kind: 'rate_limited', retryAt: error.retryAt };
105
+ }
106
+ if (error instanceof SyncAuthError) return { kind: 'auth' };
107
+ if (error instanceof SyncPartitionMismatchError) return { kind: 'partition_mismatch' };
108
+ if (error instanceof SyncNetworkError) return { kind: 'network' };
109
+ if (error instanceof SyncServerError) return { kind: 'server' };
110
+ if (error instanceof SyncPayloadCompatibilityError) return { kind: 'payload_compatibility' };
111
+ if (error instanceof SyncLocalApplyError) return { kind: 'local_apply' };
112
+ if (error instanceof SyncTransportError) {
113
+ return error.retryable ? { kind: 'network' } : { kind: 'server' };
114
+ }
115
+ return { kind: 'local_apply' };
116
+ }
@@ -0,0 +1,137 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const runtime = vi.hoisted(() => ({
4
+ appState: 'active',
5
+ platform: 'ios',
6
+ appStateListener: undefined as ((state: string) => void) | undefined,
7
+ networkListener: undefined as
8
+ ((state: { isConnected?: boolean; isInternetReachable?: boolean }) => void) | undefined,
9
+ removeAppState: vi.fn(),
10
+ removeNetwork: vi.fn(),
11
+ getNetworkStateAsync: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('react-native', () => ({
15
+ AppState: {
16
+ get currentState() {
17
+ return runtime.appState;
18
+ },
19
+ addEventListener: vi.fn(
20
+ (_event: string, listener: (state: string) => void): { remove(): void } => {
21
+ runtime.appStateListener = listener;
22
+ return { remove: runtime.removeAppState };
23
+ },
24
+ ),
25
+ },
26
+ Platform: {
27
+ get OS() {
28
+ return runtime.platform;
29
+ },
30
+ },
31
+ }));
32
+
33
+ vi.mock('expo-network', () => ({
34
+ getNetworkStateAsync: runtime.getNetworkStateAsync,
35
+ addNetworkStateListener: vi.fn(
36
+ (
37
+ listener: (state: { isConnected?: boolean; isInternetReachable?: boolean }) => void,
38
+ ): { remove(): void } => {
39
+ runtime.networkListener = listener;
40
+ return { remove: runtime.removeNetwork };
41
+ },
42
+ ),
43
+ }));
44
+
45
+ import { createExpoSyncEnvironment } from './expo.js';
46
+ import { createExpoSyncEnvironment as createFromPackageExport } from '@baukit/sync-client/expo';
47
+
48
+ describe('createExpoSyncEnvironment', () => {
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ runtime.appState = 'active';
52
+ runtime.platform = 'ios';
53
+ runtime.appStateListener = undefined;
54
+ runtime.networkListener = undefined;
55
+ runtime.getNetworkStateAsync.mockResolvedValue({
56
+ isConnected: true,
57
+ isInternetReachable: true,
58
+ });
59
+ });
60
+
61
+ it('resolves the built Expo package export', () => {
62
+ expect(createFromPackageExport).toBeTypeOf('function');
63
+ });
64
+
65
+ it('maps AppState changes onto active state and removes the subscription', () => {
66
+ const listener = vi.fn();
67
+ const environment = createExpoSyncEnvironment();
68
+
69
+ expect(environment.isActive()).toBe(true);
70
+ const unsubscribe = environment.subscribeActive(listener);
71
+ runtime.appStateListener?.('background');
72
+ runtime.appStateListener?.('active');
73
+
74
+ expect(listener.mock.calls).toEqual([[false], [true]]);
75
+ unsubscribe();
76
+ expect(runtime.removeAppState).toHaveBeenCalledOnce();
77
+ });
78
+
79
+ it('reports a native transition from offline to usable connectivity once', async () => {
80
+ runtime.getNetworkStateAsync.mockResolvedValue({
81
+ isConnected: false,
82
+ isInternetReachable: false,
83
+ });
84
+ const listener = vi.fn();
85
+ const unsubscribe = createExpoSyncEnvironment().subscribeOnline(listener);
86
+ await Promise.resolve();
87
+
88
+ runtime.networkListener?.({ isConnected: true, isInternetReachable: false });
89
+ runtime.networkListener?.({ isConnected: true });
90
+ runtime.networkListener?.({ isConnected: true, isInternetReachable: true });
91
+
92
+ expect(listener).toHaveBeenCalledOnce();
93
+ unsubscribe();
94
+ expect(runtime.removeNetwork).toHaveBeenCalledOnce();
95
+ });
96
+
97
+ it('uses browser online events on Expo web', () => {
98
+ runtime.platform = 'web';
99
+ const addEventListener = vi.fn();
100
+ const removeEventListener = vi.fn();
101
+ const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
102
+ Object.defineProperty(globalThis, 'window', {
103
+ configurable: true,
104
+ value: { addEventListener, removeEventListener },
105
+ });
106
+ const listener = vi.fn();
107
+
108
+ try {
109
+ const unsubscribe = createExpoSyncEnvironment().subscribeOnline(listener);
110
+ expect(addEventListener).toHaveBeenCalledWith('online', listener);
111
+ unsubscribe();
112
+ expect(removeEventListener).toHaveBeenCalledWith('online', listener);
113
+ } finally {
114
+ if (originalWindow) {
115
+ Object.defineProperty(globalThis, 'window', originalWindow);
116
+ } else {
117
+ Reflect.deleteProperty(globalThis, 'window');
118
+ }
119
+ }
120
+ });
121
+
122
+ it('uses injected timers', () => {
123
+ const handle = {};
124
+ const timers = {
125
+ setInterval: vi.fn(() => handle),
126
+ clearInterval: vi.fn(),
127
+ };
128
+ const environment = createExpoSyncEnvironment({ timers });
129
+ const callback = vi.fn();
130
+
131
+ expect(environment.setInterval(callback, 1234)).toBe(handle);
132
+ environment.clearInterval(handle);
133
+
134
+ expect(timers.setInterval).toHaveBeenCalledWith(callback, 1234);
135
+ expect(timers.clearInterval).toHaveBeenCalledWith(handle);
136
+ });
137
+ });
package/src/expo.ts ADDED
@@ -0,0 +1,101 @@
1
+ import * as Network from 'expo-network';
2
+ import { AppState, Platform } from 'react-native';
3
+
4
+ import type { SyncSchedulerEnvironment, SyncSchedulerTimer } from './scheduler.js';
5
+
6
+ export interface ExpoSyncTimers {
7
+ setInterval(callback: () => void, milliseconds: number): SyncSchedulerTimer;
8
+ clearInterval(handle: SyncSchedulerTimer): void;
9
+ }
10
+
11
+ export interface ExpoSyncEnvironmentOptions {
12
+ /** Overrides global timers, primarily for deterministic scheduler tests. */
13
+ timers?: ExpoSyncTimers;
14
+ }
15
+
16
+ interface ExpoNetworkModule {
17
+ getNetworkStateAsync(): Promise<Network.NetworkState>;
18
+ addNetworkStateListener?: (listener: (state: Network.NetworkState) => void) => { remove(): void };
19
+ }
20
+
21
+ const defaultTimers: ExpoSyncTimers = {
22
+ setInterval: (callback, milliseconds) =>
23
+ globalThis.setInterval(callback, milliseconds) as unknown as SyncSchedulerTimer,
24
+ clearInterval: (handle) => {
25
+ globalThis.clearInterval(handle as unknown as ReturnType<typeof globalThis.setInterval>);
26
+ },
27
+ };
28
+
29
+ function isUsableNetwork(state: Network.NetworkState): boolean {
30
+ return state.isConnected === true && state.isInternetReachable !== false;
31
+ }
32
+
33
+ function subscribeBrowserOnline(listener: () => void): () => void {
34
+ if (typeof window === 'undefined') {
35
+ return () => undefined;
36
+ }
37
+ window.addEventListener('online', listener);
38
+ return () => {
39
+ window.removeEventListener('online', listener);
40
+ };
41
+ }
42
+
43
+ function subscribeNativeOnline(listener: () => void): () => void {
44
+ const network = Network as ExpoNetworkModule;
45
+ let subscribed = true;
46
+ let receivedEvent = false;
47
+ let wasUsable: boolean | null = null;
48
+
49
+ void network
50
+ .getNetworkStateAsync()
51
+ .then((state) => {
52
+ if (subscribed && !receivedEvent) {
53
+ wasUsable = isUsableNetwork(state);
54
+ }
55
+ })
56
+ .catch(() => undefined);
57
+
58
+ const subscription = network.addNetworkStateListener?.((state) => {
59
+ if (!subscribed) return;
60
+ receivedEvent = true;
61
+ const usable = isUsableNetwork(state);
62
+ if (wasUsable === false && usable) {
63
+ listener();
64
+ }
65
+ wasUsable = usable;
66
+ });
67
+
68
+ return () => {
69
+ subscribed = false;
70
+ subscription?.remove();
71
+ };
72
+ }
73
+
74
+ /** Creates the Expo runtime environment consumed by {@link SyncScheduler}. */
75
+ export function createExpoSyncEnvironment(
76
+ options: ExpoSyncEnvironmentOptions = {},
77
+ ): SyncSchedulerEnvironment {
78
+ const timers = options.timers ?? defaultTimers;
79
+ return {
80
+ isActive: () => AppState.currentState === 'active',
81
+ subscribeActive(listener) {
82
+ const subscription = AppState.addEventListener('change', (state) => {
83
+ listener(state === 'active');
84
+ });
85
+ return () => {
86
+ subscription.remove();
87
+ };
88
+ },
89
+ subscribeOnline(listener) {
90
+ return Platform.OS === 'web'
91
+ ? subscribeBrowserOnline(listener)
92
+ : subscribeNativeOnline(listener);
93
+ },
94
+ setInterval(callback, milliseconds) {
95
+ return timers.setInterval(callback, milliseconds);
96
+ },
97
+ clearInterval(handle) {
98
+ timers.clearInterval(handle);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,249 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ compareHybridLogicalTimestamps,
7
+ decodeHybridLogicalTimestamp,
8
+ encodeHybridLogicalTimestamp,
9
+ HybridLogicalClock,
10
+ HybridLogicalClockError,
11
+ MAX_HLC_TIMESTAMP,
12
+ type HlcStorage,
13
+ type HybridLogicalClockState,
14
+ } from './hlc.js';
15
+
16
+ interface EncodeVector {
17
+ readonly wallTimeMs: number;
18
+ readonly counter: number;
19
+ readonly timestamp: number;
20
+ }
21
+
22
+ interface CompareVector {
23
+ readonly left: number;
24
+ readonly right: number;
25
+ readonly ordering: -1 | 0 | 1;
26
+ }
27
+
28
+ interface MergeVector {
29
+ readonly deviceId: string;
30
+ readonly initial: HybridLogicalClockState;
31
+ readonly physicalTimeMs: number;
32
+ readonly remoteTimestamp: number;
33
+ readonly expectedTimestamp: number;
34
+ readonly expectedState: HybridLogicalClockState;
35
+ }
36
+
37
+ interface HlcVectors {
38
+ readonly encode: readonly EncodeVector[];
39
+ readonly compare: readonly CompareVector[];
40
+ readonly merge: readonly MergeVector[];
41
+ }
42
+
43
+ class MemoryStorage implements HlcStorage {
44
+ readonly values = new Map<string, unknown>();
45
+
46
+ public get(key: string): Promise<unknown> {
47
+ return Promise.resolve(this.values.get(key));
48
+ }
49
+
50
+ public set(key: string, value: HybridLogicalClockState): Promise<void> {
51
+ this.values.set(key, structuredClone(value));
52
+ return Promise.resolve();
53
+ }
54
+ }
55
+
56
+ class BlockingStorage extends MemoryStorage {
57
+ readonly writes: HybridLogicalClockState[] = [];
58
+ readonly firstWriteStarted: Promise<void>;
59
+ #reportFirstWrite: () => void = () => undefined;
60
+ #releaseFirstWrite: () => void = () => undefined;
61
+ readonly #firstWriteGate: Promise<void>;
62
+
63
+ public constructor() {
64
+ super();
65
+ this.firstWriteStarted = new Promise((resolve) => {
66
+ this.#reportFirstWrite = resolve;
67
+ });
68
+ this.#firstWriteGate = new Promise((resolve) => {
69
+ this.#releaseFirstWrite = resolve;
70
+ });
71
+ }
72
+
73
+ public override async set(key: string, value: HybridLogicalClockState): Promise<void> {
74
+ this.writes.push(structuredClone(value));
75
+ if (this.writes.length === 1) {
76
+ this.#reportFirstWrite();
77
+ await this.#firstWriteGate;
78
+ }
79
+ await super.set(key, value);
80
+ }
81
+
82
+ public releaseFirstWrite(): void {
83
+ this.#releaseFirstWrite();
84
+ }
85
+ }
86
+
87
+ class FailingOnceStorage extends MemoryStorage {
88
+ #failed = false;
89
+
90
+ public override set(key: string, value: HybridLogicalClockState): Promise<void> {
91
+ if (!this.#failed) {
92
+ this.#failed = true;
93
+ return Promise.reject(new Error('write failed'));
94
+ }
95
+ return super.set(key, value);
96
+ }
97
+ }
98
+
99
+ const fixtureUrl = new URL('../../../../fixtures/hlc/vectors-v1.json', import.meta.url);
100
+ const readUtf8File = readFileSync as unknown as (path: URL, encoding: 'utf8') => string;
101
+ const vectors = JSON.parse(readUtf8File(fixtureUrl, 'utf8')) as HlcVectors;
102
+
103
+ describe('HybridLogicalClock', () => {
104
+ it('advances while physical time stalls and moves backward', async () => {
105
+ let physical = 1_000;
106
+ const clock = await HybridLogicalClock.open('device-a', undefined, () => physical);
107
+
108
+ const first = await clock.now();
109
+ const stalled = await clock.now();
110
+ physical = 900;
111
+ const backward = await clock.now();
112
+
113
+ expect(first).toBeLessThan(stalled);
114
+ expect(stalled).toBeLessThan(backward);
115
+ expect(decodeHybridLogicalTimestamp(backward)).toEqual({ wallTimeMs: 1_000, counter: 2 });
116
+ });
117
+
118
+ it('rolls the logical counter into the next millisecond', async () => {
119
+ const storage = new MemoryStorage();
120
+ storage.values.set('hlc-state', { wallTimeMs: 1_000, counter: 999, deviceId: 'device-a' });
121
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 1_000);
122
+
123
+ await expect(clock.now()).resolves.toBe(1_001_001);
124
+ expect(clock.snapshot()).toEqual({ wallTimeMs: 1_001, counter: 0, deviceId: 'device-a' });
125
+ });
126
+
127
+ it('restores matching state', async () => {
128
+ const storage = new MemoryStorage();
129
+ storage.values.set('hlc-state', { wallTimeMs: 2_000, counter: 7, deviceId: 'device-a' });
130
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 1_000);
131
+
132
+ await expect(clock.now()).resolves.toBe(2_000_009);
133
+ expect(clock.snapshot()).toEqual({ wallTimeMs: 2_000, counter: 8, deviceId: 'device-a' });
134
+ });
135
+
136
+ it.each([
137
+ null,
138
+ { wallTimeMs: -1, counter: 0, deviceId: 'device-a' },
139
+ { wallTimeMs: 1_000, counter: 1_000, deviceId: 'device-a' },
140
+ { wallTimeMs: Number.MAX_SAFE_INTEGER, counter: 0, deviceId: 'device-a' },
141
+ { wallTimeMs: 1_000, counter: 2, deviceId: 'device-b' },
142
+ ])('resets corrupt or foreign stored state %#', async (stored) => {
143
+ const storage = new MemoryStorage();
144
+ storage.values.set('hlc-state', stored);
145
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 10);
146
+
147
+ await expect(clock.now()).resolves.toBe(10_001);
148
+ });
149
+
150
+ it('observes remote time and leaves state unchanged for invalid input', async () => {
151
+ const clock = await HybridLogicalClock.open('device-a', undefined, () => 900);
152
+
153
+ await expect(clock.observe(1_000_006)).resolves.toBe(1_000_007);
154
+ const snapshot = clock.snapshot();
155
+ await expect(clock.observe(0)).rejects.toMatchObject({ code: 'invalid_timestamp' });
156
+ expect(clock.snapshot()).toEqual(snapshot);
157
+ expect(compareHybridLogicalTimestamps(0, 1)).toBeNull();
158
+ });
159
+
160
+ it('serializes concurrent persisted calls', async () => {
161
+ const storage = new BlockingStorage();
162
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 1_000);
163
+
164
+ const first = clock.now();
165
+ const second = clock.now();
166
+ const observed = clock.observe(1_000_010);
167
+ await storage.firstWriteStarted;
168
+ expect(storage.writes).toHaveLength(1);
169
+ storage.releaseFirstWrite();
170
+
171
+ await expect(Promise.all([first, second, observed])).resolves.toEqual([
172
+ 1_000_001, 1_000_002, 1_000_011,
173
+ ]);
174
+ expect(storage.writes).toHaveLength(3);
175
+ expect(clock.snapshot()).toEqual({ wallTimeMs: 1_000, counter: 10, deviceId: 'device-a' });
176
+ });
177
+
178
+ it('continues the persistence queue after a failed write', async () => {
179
+ const storage = new FailingOnceStorage();
180
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 1_000);
181
+
182
+ await expect(clock.now()).rejects.toThrow('write failed');
183
+ expect(clock.snapshot()).toEqual({ wallTimeMs: 0, counter: 0, deviceId: 'device-a' });
184
+ await expect(clock.now()).resolves.toBe(1_000_001);
185
+ });
186
+
187
+ it('supports the maximum encoded value and reports exhaustion', async () => {
188
+ expect(encodeHybridLogicalTimestamp(9_007_199_254_740, 990)).toBe(MAX_HLC_TIMESTAMP);
189
+ expect(decodeHybridLogicalTimestamp(MAX_HLC_TIMESTAMP)).toEqual({
190
+ wallTimeMs: 9_007_199_254_740,
191
+ counter: 990,
192
+ });
193
+ expect(() => encodeHybridLogicalTimestamp(9_007_199_254_740, 991)).toThrow(
194
+ HybridLogicalClockError,
195
+ );
196
+
197
+ const storage = new MemoryStorage();
198
+ storage.values.set('hlc-state', {
199
+ wallTimeMs: 9_007_199_254_740,
200
+ counter: 990,
201
+ deviceId: 'device-a',
202
+ });
203
+ const clock = await HybridLogicalClock.open('device-a', storage, () => 0);
204
+ await expect(clock.now()).rejects.toMatchObject({ code: 'exceeds_safe_integer' });
205
+ expect(clock.snapshot()).toEqual({
206
+ wallTimeMs: 9_007_199_254_740,
207
+ counter: 990,
208
+ deviceId: 'device-a',
209
+ });
210
+ });
211
+
212
+ it('rejects a blank device id and invalid physical time with typed errors', async () => {
213
+ await expect(HybridLogicalClock.open(' ')).rejects.toMatchObject({
214
+ code: 'empty_device_id',
215
+ });
216
+ const clock = await HybridLogicalClock.open('device-a', undefined, () => -1);
217
+ await expect(clock.now()).rejects.toMatchObject({ code: 'invalid_physical_clock' });
218
+ });
219
+ });
220
+
221
+ describe('cross-runtime HLC vectors', () => {
222
+ it('matches the Rust encoding, comparison, observation, and state corpus', async () => {
223
+ for (const vector of vectors.encode) {
224
+ expect(encodeHybridLogicalTimestamp(vector.wallTimeMs, vector.counter)).toBe(
225
+ vector.timestamp,
226
+ );
227
+ expect(decodeHybridLogicalTimestamp(vector.timestamp)).toEqual({
228
+ wallTimeMs: vector.wallTimeMs,
229
+ counter: vector.counter,
230
+ });
231
+ }
232
+
233
+ for (const vector of vectors.compare) {
234
+ expect(compareHybridLogicalTimestamps(vector.left, vector.right)).toBe(vector.ordering);
235
+ }
236
+
237
+ for (const vector of vectors.merge) {
238
+ const storage = new MemoryStorage();
239
+ storage.values.set('hlc-state', vector.initial);
240
+ const clock = await HybridLogicalClock.open(
241
+ vector.deviceId,
242
+ storage,
243
+ () => vector.physicalTimeMs,
244
+ );
245
+ await expect(clock.observe(vector.remoteTimestamp)).resolves.toBe(vector.expectedTimestamp);
246
+ expect(clock.snapshot()).toEqual(vector.expectedState);
247
+ }
248
+ });
249
+ });