@syncular/client 0.15.45 → 0.15.46

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/remote.ts ADDED
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Database-less SSP2 producer (§6.10). It prepares and sends ordinary commits
3
+ * through the existing push path without creating a local replica or outbox.
4
+ */
5
+ import {
6
+ decodeMessage,
7
+ decodeRemoteOperationResponse,
8
+ decodeRemoteOperationRealtimeMessage,
9
+ encodeMessage,
10
+ encodeRemoteOperationRequest,
11
+ encodeRemoteOperationRealtimeMessage,
12
+ encodeRow,
13
+ PROTOCOL_WIRE_VERSION,
14
+ type PushOperation,
15
+ type PushOperationResult,
16
+ type PushResultDetailsFrame,
17
+ type PushResultFrame,
18
+ type RemoteOperationResponse,
19
+ } from '@syncular/core';
20
+ import type { MutationInput } from './client';
21
+ import type { EncryptionConfig } from './encryption';
22
+ import { encryptRowValues } from './encryption';
23
+ import { ClientSyncError } from './errors';
24
+ import {
25
+ compileClientSchema,
26
+ type ClientSchema,
27
+ recordToRowValues,
28
+ } from './schema';
29
+ import type { SyncTransport } from './transport';
30
+ import type {
31
+ RemoteOperationRealtimeConnector,
32
+ RemoteOperationRealtimeSocket,
33
+ RemoteOperationTransport,
34
+ } from './transport';
35
+
36
+ export interface SyncRemoteClientConfig {
37
+ /** Required only for ordinary row commits. */
38
+ readonly schema?: ClientSchema;
39
+ readonly clientId: string;
40
+ /** Required only for ordinary row commits. */
41
+ readonly transport?: SyncTransport;
42
+ readonly operations?: RemoteOperationTransport;
43
+ readonly operationRealtime?: RemoteOperationRealtimeConnector;
44
+ readonly encryption?: EncryptionConfig;
45
+ }
46
+
47
+ export interface RemoteCommitInput {
48
+ /** Stable caller-owned idempotency identity for this logical commit. */
49
+ readonly requestId: string;
50
+ readonly mutations: readonly MutationInput[];
51
+ }
52
+
53
+ /**
54
+ * Prepared bytes are the retry unit. Persist them when a process must retry
55
+ * across restart, especially when encryption uses randomized nonces.
56
+ */
57
+ export interface PreparedRemoteCommit {
58
+ readonly requestId: string;
59
+ readonly bytes: Uint8Array;
60
+ }
61
+
62
+ export interface RemoteCommitResult {
63
+ readonly requestId: string;
64
+ readonly status: PushResultFrame['status'];
65
+ readonly commitSeq?: number;
66
+ readonly results: readonly PushOperationResult[];
67
+ }
68
+
69
+ export interface RemoteQueryDescriptor<Row, Params = undefined> {
70
+ readonly id: string;
71
+ readonly mapRow: (row: Readonly<Record<string, unknown>>) => Row;
72
+ readonly __row?: Row;
73
+ readonly __params?: Params;
74
+ }
75
+
76
+ export interface RemoteQueryResult<Row> {
77
+ readonly rows: readonly Row[];
78
+ readonly maxCommitSeq: number;
79
+ }
80
+
81
+ export interface RemoteQueryWatchHandlers<Row> {
82
+ onSnapshot(result: RemoteQueryResult<Row>): void;
83
+ onError?(error: ClientSyncError): void;
84
+ }
85
+
86
+ export interface RemoteCommandDescriptor<Input = undefined> {
87
+ readonly id: string;
88
+ readonly __input?: Input;
89
+ }
90
+
91
+ export function remoteCommand<Input = undefined>(
92
+ id: string,
93
+ ): RemoteCommandDescriptor<Input> {
94
+ if (id.length === 0) throw invalid('remote command id must be non-empty');
95
+ return { id };
96
+ }
97
+
98
+ export interface RemoteCommandResult {
99
+ readonly requestId: string;
100
+ readonly status: 'applied' | 'cached' | 'rejected';
101
+ readonly commitSeq?: number;
102
+ readonly results: readonly unknown[];
103
+ }
104
+
105
+ function invalid(message: string): ClientSyncError {
106
+ return new ClientSyncError('sync.invalid_request', message);
107
+ }
108
+
109
+ function operationResponse(bytes: Uint8Array): RemoteOperationResponse {
110
+ let response: RemoteOperationResponse;
111
+ try {
112
+ response = decodeRemoteOperationResponse(bytes);
113
+ } catch {
114
+ throw new ClientSyncError(
115
+ 'client.invalid_host_response',
116
+ 'remote operation response is malformed',
117
+ );
118
+ }
119
+ if (
120
+ typeof response !== 'object' ||
121
+ response === null ||
122
+ Array.isArray(response)
123
+ ) {
124
+ throw new ClientSyncError(
125
+ 'client.invalid_host_response',
126
+ 'remote operation response is malformed',
127
+ );
128
+ }
129
+ if (response.revision !== 1) {
130
+ throw new ClientSyncError(
131
+ 'client.invalid_host_response',
132
+ 'remote operation response has an unsupported revision',
133
+ );
134
+ }
135
+ if (
136
+ response.kind === 'error' &&
137
+ (typeof response.code !== 'string' ||
138
+ typeof response.message !== 'string' ||
139
+ typeof response.retryable !== 'boolean')
140
+ ) {
141
+ throw new ClientSyncError(
142
+ 'client.invalid_host_response',
143
+ 'remote operation error response is malformed',
144
+ );
145
+ }
146
+ if (
147
+ response.kind === 'query' &&
148
+ (typeof response.operationId !== 'string' ||
149
+ !Array.isArray(response.rows) ||
150
+ response.rows.some(
151
+ (row) => typeof row !== 'object' || row === null || Array.isArray(row),
152
+ ) ||
153
+ !Number.isSafeInteger(response.maxCommitSeq) ||
154
+ response.maxCommitSeq < 0)
155
+ ) {
156
+ throw new ClientSyncError(
157
+ 'client.invalid_host_response',
158
+ 'remote query response is malformed',
159
+ );
160
+ }
161
+ if (
162
+ response.kind === 'command' &&
163
+ (typeof response.operationId !== 'string' ||
164
+ typeof response.requestId !== 'string' ||
165
+ !['applied', 'cached', 'rejected'].includes(response.status) ||
166
+ !Array.isArray(response.results) ||
167
+ (response.commitSeq !== undefined &&
168
+ (!Number.isSafeInteger(response.commitSeq) || response.commitSeq < 1)))
169
+ ) {
170
+ throw new ClientSyncError(
171
+ 'client.invalid_host_response',
172
+ 'remote command response is malformed',
173
+ );
174
+ }
175
+ if (
176
+ response.kind !== 'error' &&
177
+ response.kind !== 'query' &&
178
+ response.kind !== 'command'
179
+ ) {
180
+ throw new ClientSyncError(
181
+ 'client.invalid_host_response',
182
+ 'remote operation response has an unknown kind',
183
+ );
184
+ }
185
+ return response;
186
+ }
187
+
188
+ export class SyncRemoteClient {
189
+ readonly #schema;
190
+ readonly #clientId: string;
191
+ readonly #transport: SyncTransport | undefined;
192
+ readonly #operations: RemoteOperationTransport | undefined;
193
+ readonly #operationRealtime: RemoteOperationRealtimeConnector | undefined;
194
+ #operationSocket: RemoteOperationRealtimeSocket | undefined;
195
+ #operationSocketPromise: Promise<RemoteOperationRealtimeSocket> | undefined;
196
+ #operationSocketGeneration = 0;
197
+ readonly #watches = new Map<
198
+ string,
199
+ {
200
+ readonly operationId: string;
201
+ readonly mapRow: (row: Readonly<Record<string, unknown>>) => unknown;
202
+ readonly handlers: RemoteQueryWatchHandlers<unknown>;
203
+ }
204
+ >();
205
+ readonly #encryption: EncryptionConfig | undefined;
206
+
207
+ constructor(config: SyncRemoteClientConfig) {
208
+ if (config.clientId.length === 0) {
209
+ throw invalid('SyncRemoteClient clientId must be non-empty');
210
+ }
211
+ this.#schema =
212
+ config.schema === undefined
213
+ ? undefined
214
+ : compileClientSchema(config.schema);
215
+ this.#clientId = config.clientId;
216
+ this.#transport = config.transport;
217
+ this.#operations = config.operations;
218
+ this.#operationRealtime = config.operationRealtime;
219
+ this.#encryption = config.encryption;
220
+ }
221
+
222
+ async prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit> {
223
+ const schema = this.#schema;
224
+ if (schema === undefined) {
225
+ throw new ClientSyncError(
226
+ 'client.remote_schema_unconfigured',
227
+ 'SyncRemoteClient needs a schema to prepare ordinary commits',
228
+ );
229
+ }
230
+ if (input.requestId.length === 0) {
231
+ throw invalid('remote commit requestId must be non-empty');
232
+ }
233
+ if (input.mutations.length === 0) {
234
+ throw new ClientSyncError(
235
+ 'sync.empty_commit',
236
+ 'a remote commit must carry at least one mutation (§6.1)',
237
+ );
238
+ }
239
+ const operations: PushOperation[] = [];
240
+ for (const mutation of input.mutations) {
241
+ const table = schema.tables.get(mutation.table);
242
+ if (table === undefined) {
243
+ throw invalid('remote commit targets an unknown table');
244
+ }
245
+ if (mutation.op === 'delete') {
246
+ if (mutation.rowId.length === 0) {
247
+ throw invalid('remote delete rowId must be non-empty');
248
+ }
249
+ operations.push({
250
+ table: mutation.table,
251
+ rowId: mutation.rowId,
252
+ op: 'delete',
253
+ ...(mutation.baseVersion !== undefined
254
+ ? { baseVersion: mutation.baseVersion }
255
+ : {}),
256
+ });
257
+ continue;
258
+ }
259
+ let values = recordToRowValues(table, mutation.values);
260
+ const rowId = values[table.primaryKeyIndex];
261
+ if (typeof rowId !== 'string' || rowId.length === 0) {
262
+ throw invalid('remote upsert requires a non-empty string primary key');
263
+ }
264
+ if (this.#encryption !== undefined && table.hasEncryptedColumns) {
265
+ values = await encryptRowValues(this.#encryption, table, rowId, values);
266
+ }
267
+ operations.push({
268
+ table: mutation.table,
269
+ rowId,
270
+ op: 'upsert',
271
+ ...(mutation.baseVersion !== undefined
272
+ ? { baseVersion: mutation.baseVersion }
273
+ : {}),
274
+ payload: encodeRow(table.columns, values),
275
+ });
276
+ }
277
+ return {
278
+ requestId: input.requestId,
279
+ bytes: encodeMessage({
280
+ wireVersion: PROTOCOL_WIRE_VERSION,
281
+ msgKind: 'request',
282
+ frames: [
283
+ {
284
+ type: 'REQ_HEADER',
285
+ clientId: this.#clientId,
286
+ schemaVersion: schema.version,
287
+ },
288
+ {
289
+ type: 'PUSH_COMMIT',
290
+ clientCommitId: input.requestId,
291
+ operations,
292
+ },
293
+ ],
294
+ }),
295
+ };
296
+ }
297
+
298
+ async sendCommit(
299
+ prepared: PreparedRemoteCommit,
300
+ ): Promise<RemoteCommitResult> {
301
+ if (this.#transport === undefined) {
302
+ throw new ClientSyncError(
303
+ 'client.remote_sync_unconfigured',
304
+ 'SyncRemoteClient has no sync transport for ordinary commits',
305
+ );
306
+ }
307
+ const response = decodeMessage(await this.#transport(prepared.bytes));
308
+ if (response.msgKind !== 'response') {
309
+ throw new ClientSyncError(
310
+ 'client.invalid_host_response',
311
+ 'remote commit transport returned a non-response SSP2 message',
312
+ );
313
+ }
314
+ const error = response.frames.find((frame) => frame.type === 'ERROR');
315
+ if (error?.type === 'ERROR') {
316
+ throw new ClientSyncError(error.code, error.message, error.retryable);
317
+ }
318
+ const result = response.frames.find(
319
+ (frame): frame is PushResultFrame =>
320
+ frame.type === 'PUSH_RESULT' &&
321
+ frame.clientCommitId === prepared.requestId,
322
+ );
323
+ if (result === undefined) {
324
+ throw new ClientSyncError(
325
+ 'client.invalid_host_response',
326
+ 'remote commit response carried no matching PUSH_RESULT',
327
+ );
328
+ }
329
+ const details = response.frames.find(
330
+ (frame): frame is PushResultDetailsFrame =>
331
+ frame.type === 'PUSH_RESULT_DETAILS' &&
332
+ frame.clientCommitId === prepared.requestId,
333
+ );
334
+ const detailsByIndex = new Map(
335
+ details?.entries.map((entry) => [entry.opIndex, entry.details]) ?? [],
336
+ );
337
+ return {
338
+ requestId: prepared.requestId,
339
+ status: result.status,
340
+ ...(result.commitSeq !== undefined
341
+ ? { commitSeq: result.commitSeq }
342
+ : {}),
343
+ results: result.results.map((operation) => {
344
+ if (operation.status !== 'error') return operation;
345
+ const operationDetails = detailsByIndex.get(operation.opIndex);
346
+ return operationDetails === undefined
347
+ ? operation
348
+ : { ...operation, details: operationDetails };
349
+ }),
350
+ };
351
+ }
352
+
353
+ async commit(input: RemoteCommitInput): Promise<RemoteCommitResult> {
354
+ return this.sendCommit(await this.prepareCommit(input));
355
+ }
356
+
357
+ async query<Row, Params = undefined>(
358
+ descriptor: RemoteQueryDescriptor<Row, Params>,
359
+ params?: Params,
360
+ ): Promise<RemoteQueryResult<Row>> {
361
+ if (this.#operations === undefined) {
362
+ throw new ClientSyncError(
363
+ 'client.remote_operations_unconfigured',
364
+ 'SyncRemoteClient has no remote operation transport',
365
+ );
366
+ }
367
+ const response = operationResponse(
368
+ await this.#operations(
369
+ encodeRemoteOperationRequest({
370
+ revision: 1,
371
+ kind: 'query',
372
+ clientId: this.#clientId,
373
+ operationId: descriptor.id,
374
+ params: params ?? null,
375
+ }),
376
+ ),
377
+ );
378
+ if (response.kind === 'error') {
379
+ throw new ClientSyncError(
380
+ response.code,
381
+ response.message,
382
+ response.retryable,
383
+ );
384
+ }
385
+ if (response.kind !== 'query' || response.operationId !== descriptor.id) {
386
+ throw new ClientSyncError(
387
+ 'client.invalid_host_response',
388
+ 'remote query returned a mismatched response',
389
+ );
390
+ }
391
+ try {
392
+ return {
393
+ rows: response.rows.map((row) => descriptor.mapRow(row)),
394
+ maxCommitSeq: response.maxCommitSeq,
395
+ };
396
+ } catch {
397
+ throw new ClientSyncError(
398
+ 'client.invalid_host_response',
399
+ 'remote query row is malformed',
400
+ );
401
+ }
402
+ }
403
+
404
+ async command<Input = undefined>(
405
+ descriptor: RemoteCommandDescriptor<Input>,
406
+ requestId: string,
407
+ input?: Input,
408
+ ): Promise<RemoteCommandResult> {
409
+ if (this.#operations === undefined) {
410
+ throw new ClientSyncError(
411
+ 'client.remote_operations_unconfigured',
412
+ 'SyncRemoteClient has no remote operation transport',
413
+ );
414
+ }
415
+ if (requestId.length === 0) {
416
+ throw invalid('remote command requestId must be non-empty');
417
+ }
418
+ const response = operationResponse(
419
+ await this.#operations(
420
+ encodeRemoteOperationRequest({
421
+ revision: 1,
422
+ kind: 'command',
423
+ clientId: this.#clientId,
424
+ operationId: descriptor.id,
425
+ requestId,
426
+ params: input ?? null,
427
+ }),
428
+ ),
429
+ );
430
+ if (response.kind === 'error') {
431
+ throw new ClientSyncError(
432
+ response.code,
433
+ response.message,
434
+ response.retryable,
435
+ );
436
+ }
437
+ if (
438
+ response.kind !== 'command' ||
439
+ response.operationId !== descriptor.id ||
440
+ response.requestId !== requestId
441
+ ) {
442
+ throw new ClientSyncError(
443
+ 'client.invalid_host_response',
444
+ 'remote command returned a mismatched response',
445
+ );
446
+ }
447
+ return {
448
+ requestId,
449
+ status: response.status,
450
+ ...(response.commitSeq !== undefined
451
+ ? { commitSeq: response.commitSeq }
452
+ : {}),
453
+ results: response.results,
454
+ };
455
+ }
456
+
457
+ async watch<Row, Params = undefined>(
458
+ descriptor: RemoteQueryDescriptor<Row, Params>,
459
+ params: Params,
460
+ handlers: RemoteQueryWatchHandlers<Row>,
461
+ ): Promise<() => void> {
462
+ if (this.#operationRealtime === undefined) {
463
+ throw new ClientSyncError(
464
+ 'client.remote_realtime_unconfigured',
465
+ 'SyncRemoteClient has no remote operation realtime connector',
466
+ );
467
+ }
468
+ const watchId = crypto.randomUUID();
469
+ this.#watches.set(watchId, {
470
+ operationId: descriptor.id,
471
+ mapRow: descriptor.mapRow,
472
+ handlers: handlers as RemoteQueryWatchHandlers<unknown>,
473
+ });
474
+ let socket: RemoteOperationRealtimeSocket;
475
+ try {
476
+ socket = await this.#operationRealtimeSocket();
477
+ } catch (error) {
478
+ this.#watches.delete(watchId);
479
+ throw error;
480
+ }
481
+ if (!this.#watches.has(watchId)) {
482
+ throw new ClientSyncError(
483
+ 'client.remote_realtime_cancelled',
484
+ 'remote operation watch was cancelled before registration',
485
+ );
486
+ }
487
+ try {
488
+ socket.send(
489
+ encodeRemoteOperationRealtimeMessage({
490
+ revision: 1,
491
+ kind: 'watch',
492
+ watchId,
493
+ clientId: this.#clientId,
494
+ operationId: descriptor.id,
495
+ params: params ?? null,
496
+ }),
497
+ );
498
+ } catch {
499
+ const error = new ClientSyncError(
500
+ 'client.remote_realtime_closed',
501
+ 'remote operation realtime connection closed while registering a watch',
502
+ true,
503
+ );
504
+ this.#disconnectOperationRealtime(
505
+ this.#operationSocketGeneration,
506
+ error,
507
+ true,
508
+ );
509
+ throw error;
510
+ }
511
+ return () => {
512
+ if (!this.#watches.delete(watchId)) return;
513
+ try {
514
+ this.#operationSocket?.send(
515
+ encodeRemoteOperationRealtimeMessage({
516
+ revision: 1,
517
+ kind: 'unwatch',
518
+ watchId,
519
+ }),
520
+ );
521
+ } catch {
522
+ this.#disconnectOperationRealtime(
523
+ this.#operationSocketGeneration,
524
+ new ClientSyncError(
525
+ 'client.remote_realtime_closed',
526
+ 'remote operation realtime connection closed while removing a watch',
527
+ true,
528
+ ),
529
+ true,
530
+ );
531
+ }
532
+ };
533
+ }
534
+
535
+ async #operationRealtimeSocket(): Promise<RemoteOperationRealtimeSocket> {
536
+ if (this.#operationSocket !== undefined) return this.#operationSocket;
537
+ if (this.#operationSocketPromise !== undefined) {
538
+ return this.#operationSocketPromise;
539
+ }
540
+ const connector = this.#operationRealtime;
541
+ if (connector === undefined) {
542
+ throw new ClientSyncError(
543
+ 'client.remote_realtime_unconfigured',
544
+ 'SyncRemoteClient has no remote operation realtime connector',
545
+ );
546
+ }
547
+ const generation = this.#operationSocketGeneration;
548
+ const pending = Promise.resolve(
549
+ connector({
550
+ onMessage: (bytes) => {
551
+ if (generation !== this.#operationSocketGeneration) return;
552
+ let message;
553
+ try {
554
+ message = decodeRemoteOperationRealtimeMessage(bytes);
555
+ if (
556
+ typeof message !== 'object' ||
557
+ message === null ||
558
+ message.revision !== 1 ||
559
+ (message.kind !== 'snapshot' && message.kind !== 'watch_error') ||
560
+ typeof message.watchId !== 'string'
561
+ ) {
562
+ throw new Error('invalid remote operation realtime message');
563
+ }
564
+ } catch {
565
+ this.#disconnectOperationRealtime(
566
+ generation,
567
+ new ClientSyncError(
568
+ 'client.invalid_host_response',
569
+ 'remote operation realtime message is malformed',
570
+ ),
571
+ true,
572
+ );
573
+ return;
574
+ }
575
+ const watch = this.#watches.get(message.watchId);
576
+ if (watch === undefined) return;
577
+ if (message.kind === 'watch_error') {
578
+ if (
579
+ typeof message.code !== 'string' ||
580
+ typeof message.message !== 'string' ||
581
+ typeof message.retryable !== 'boolean'
582
+ ) {
583
+ this.#disconnectOperationRealtime(
584
+ generation,
585
+ new ClientSyncError(
586
+ 'client.invalid_host_response',
587
+ 'remote operation watch error is malformed',
588
+ ),
589
+ true,
590
+ );
591
+ return;
592
+ }
593
+ try {
594
+ watch.handlers.onError?.(
595
+ new ClientSyncError(
596
+ message.code,
597
+ message.message,
598
+ message.retryable,
599
+ ),
600
+ );
601
+ } catch {
602
+ // An observer cannot alter the connection lifecycle.
603
+ }
604
+ return;
605
+ }
606
+ if (
607
+ message.operationId !== watch.operationId ||
608
+ !Array.isArray(message.rows) ||
609
+ message.rows.some(
610
+ (row) =>
611
+ typeof row !== 'object' || row === null || Array.isArray(row),
612
+ ) ||
613
+ !Number.isSafeInteger(message.maxCommitSeq) ||
614
+ message.maxCommitSeq < 0
615
+ ) {
616
+ this.#disconnectOperationRealtime(
617
+ generation,
618
+ new ClientSyncError(
619
+ 'client.invalid_host_response',
620
+ 'remote operation watch snapshot is malformed',
621
+ ),
622
+ true,
623
+ );
624
+ return;
625
+ }
626
+ let rows: unknown[];
627
+ try {
628
+ rows = message.rows.map(watch.mapRow);
629
+ } catch {
630
+ try {
631
+ watch.handlers.onError?.(
632
+ new ClientSyncError(
633
+ 'client.invalid_host_response',
634
+ 'remote operation watch row is malformed',
635
+ ),
636
+ );
637
+ } catch {
638
+ // An observer cannot alter the connection lifecycle.
639
+ }
640
+ return;
641
+ }
642
+ try {
643
+ watch.handlers.onSnapshot({
644
+ rows,
645
+ maxCommitSeq: message.maxCommitSeq,
646
+ });
647
+ } catch {
648
+ // An observer cannot alter the connection lifecycle.
649
+ }
650
+ },
651
+ onClose: () => {
652
+ this.#disconnectOperationRealtime(
653
+ generation,
654
+ new ClientSyncError(
655
+ 'client.remote_realtime_closed',
656
+ 'remote operation realtime connection closed',
657
+ true,
658
+ ),
659
+ false,
660
+ );
661
+ },
662
+ }),
663
+ )
664
+ .then((socket) => {
665
+ if (generation !== this.#operationSocketGeneration) {
666
+ try {
667
+ socket.close();
668
+ } catch {
669
+ // The cancelled socket cannot affect the replacement generation.
670
+ }
671
+ throw new ClientSyncError(
672
+ 'client.remote_realtime_cancelled',
673
+ 'remote operation realtime connection was cancelled',
674
+ );
675
+ }
676
+ this.#operationSocket = socket;
677
+ return socket;
678
+ })
679
+ .finally(() => {
680
+ if (this.#operationSocketPromise === pending) {
681
+ this.#operationSocketPromise = undefined;
682
+ }
683
+ });
684
+ this.#operationSocketPromise = pending;
685
+ return pending;
686
+ }
687
+
688
+ #disconnectOperationRealtime(
689
+ generation: number,
690
+ error: ClientSyncError | undefined,
691
+ closeSocket: boolean,
692
+ ): void {
693
+ if (generation !== this.#operationSocketGeneration) return;
694
+ this.#operationSocketGeneration += 1;
695
+ const socket = this.#operationSocket;
696
+ this.#operationSocket = undefined;
697
+ this.#operationSocketPromise = undefined;
698
+ const watches = [...this.#watches.values()];
699
+ this.#watches.clear();
700
+ if (closeSocket) {
701
+ try {
702
+ socket?.close();
703
+ } catch {
704
+ // Local state is already disconnected.
705
+ }
706
+ }
707
+ if (error === undefined) return;
708
+ for (const watch of watches) {
709
+ try {
710
+ watch.handlers.onError?.(error);
711
+ } catch {
712
+ // An observer cannot alter the connection lifecycle.
713
+ }
714
+ }
715
+ }
716
+
717
+ close(): void {
718
+ this.#disconnectOperationRealtime(
719
+ this.#operationSocketGeneration,
720
+ undefined,
721
+ true,
722
+ );
723
+ }
724
+ }