@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/README.md CHANGED
@@ -3,6 +3,17 @@
3
3
  The TypeScript client protocol core (SPEC.md §§3–8, client side) plus its
4
4
  browser platform bindings.
5
5
 
6
+ The normal `SyncClient` also runs in a CLI or background service.
7
+ Use `openBunDatabase(path)` or `openNodeDatabase(path)` for a persistent local
8
+ replica. See the [server-side sync client guide](https://syncular.dev/guide-server-clients/).
9
+
10
+ `SyncRemoteClient` is the database-less server client. It sends ordinary
11
+ push-only commits through `/sync` and can call registered typed queries,
12
+ server-authoritative commands, and live query watches through the remote
13
+ operation transport. See [remote server operations](https://syncular.dev/guide-remote-operations/).
14
+ Its schema and sync transport are optional for query-only or command-only
15
+ processes.
16
+
6
17
  ## Client-local FTS5 projections
7
18
 
8
19
  Generated schemas may attach `ftsIndexes` to a synced table. The
package/dist/http.d.ts CHANGED
@@ -5,13 +5,17 @@
5
5
  * (the loopback doctrine); the browser fixture exercises them in a real browser.
6
6
  */
7
7
  import type { BlobTransport } from './blob.js';
8
- import type { RealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
8
+ import type { RealtimeConnector, RemoteOperationTransport, RemoteOperationRealtimeConnector, SegmentDownloader, SyncTransport } from './transport.js';
9
9
  export interface HttpTransportOptions {
10
10
  readonly headers?: Readonly<Record<string, string>>;
11
11
  readonly fetch?: typeof fetch;
12
12
  }
13
13
  /** POST `<mount>/sync` with SSP2 bodies (§1.1). */
14
14
  export declare function httpSyncTransport(syncUrl: string, options?: HttpTransportOptions): SyncTransport;
15
+ /** POST one registered authoritative operation to `<mount>/operations`. */
16
+ export declare function httpRemoteOperationTransport(operationsUrl: string, options?: HttpTransportOptions): RemoteOperationTransport;
17
+ /** WebSocket connector for registered query snapshots. */
18
+ export declare function webSocketRemoteOperationConnector(realtimeUrl: string): RemoteOperationRealtimeConnector;
15
19
  /**
16
20
  * §5.5 direct endpoint with the `X-Syncular-Scopes` re-authorization
17
21
  * header, plus the §5.4 `fetchUrl` capability (advertises accept bit 3).
package/dist/http.js CHANGED
@@ -35,6 +35,60 @@ export function httpSyncTransport(syncUrl, options) {
35
35
  return new Uint8Array(await response.arrayBuffer());
36
36
  };
37
37
  }
38
+ /** POST one registered authoritative operation to `<mount>/operations`. */
39
+ export function httpRemoteOperationTransport(operationsUrl, options) {
40
+ const doFetch = options?.fetch ?? fetch;
41
+ return async (request) => {
42
+ const response = await doFetch(operationsUrl, {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/vnd.syncular.operations.v1+json',
46
+ ...options?.headers,
47
+ },
48
+ body: request.slice().buffer,
49
+ });
50
+ if (!response.ok)
51
+ await throwHttpError(response);
52
+ return new Uint8Array(await response.arrayBuffer());
53
+ };
54
+ }
55
+ /** WebSocket connector for registered query snapshots. */
56
+ export function webSocketRemoteOperationConnector(realtimeUrl) {
57
+ return (handlers) => new Promise((resolve, reject) => {
58
+ const socket = new WebSocket(realtimeUrl);
59
+ let opened = false;
60
+ socket.binaryType = 'arraybuffer';
61
+ socket.onopen = () => {
62
+ opened = true;
63
+ resolve({
64
+ send: (bytes) => socket.send(bytes.slice().buffer),
65
+ close: () => socket.close(),
66
+ });
67
+ };
68
+ socket.onmessage = (event) => {
69
+ if (event.data instanceof ArrayBuffer) {
70
+ handlers.onMessage(new Uint8Array(event.data));
71
+ }
72
+ };
73
+ socket.onerror = () => {
74
+ if (!opened) {
75
+ reject(new ClientSyncError('sync.transport_failed', 'remote operation realtime socket failed to connect', true));
76
+ }
77
+ try {
78
+ socket.close();
79
+ }
80
+ catch {
81
+ handlers.onClose?.();
82
+ }
83
+ };
84
+ socket.onclose = () => {
85
+ if (!opened) {
86
+ reject(new ClientSyncError('sync.transport_failed', 'remote operation realtime socket closed while connecting', true));
87
+ }
88
+ handlers.onClose?.();
89
+ };
90
+ });
91
+ }
38
92
  /**
39
93
  * §5.5 direct endpoint with the `X-Syncular-Scopes` re-authorization
40
94
  * header, plus the §5.4 `fetchUrl` capability (advertises accept bit 3).
@@ -170,8 +224,10 @@ export function httpBlobTransport(blobsBaseUrl, options) {
170
224
  export function webSocketRealtimeConnector(realtimeUrl) {
171
225
  return (handlers) => new Promise((resolve, reject) => {
172
226
  const socket = new WebSocket(realtimeUrl);
227
+ let opened = false;
173
228
  socket.binaryType = 'arraybuffer';
174
229
  socket.onopen = () => {
230
+ opened = true;
175
231
  resolve({
176
232
  send: (text) => socket.send(text),
177
233
  sendBytes: (bytes) => {
@@ -187,9 +243,20 @@ export function webSocketRealtimeConnector(realtimeUrl) {
187
243
  handlers.onBinary(new Uint8Array(event.data));
188
244
  };
189
245
  socket.onerror = () => {
190
- reject(new ClientSyncError('sync.transport_failed', 'realtime socket failed to connect', true));
246
+ if (!opened) {
247
+ reject(new ClientSyncError('sync.transport_failed', 'realtime socket failed to connect', true));
248
+ }
249
+ try {
250
+ socket.close();
251
+ }
252
+ catch {
253
+ handlers.onClose?.();
254
+ }
191
255
  };
192
256
  socket.onclose = () => {
257
+ if (!opened) {
258
+ reject(new ClientSyncError('sync.transport_failed', 'realtime socket closed while connecting', true));
259
+ }
193
260
  handlers.onClose?.();
194
261
  };
195
262
  });
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export * from './outbox.js';
30
30
  export * from './outcomes.js';
31
31
  export * from './query-guard.js';
32
32
  export * from './reactive-store.js';
33
+ export * from './remote.js';
33
34
  export * from './realtime-supervisor.js';
34
35
  export * from './schema.js';
35
36
  export * from './sql-tag.js';
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ export * from './outbox.js';
30
30
  export * from './outcomes.js';
31
31
  export * from './query-guard.js';
32
32
  export * from './reactive-store.js';
33
+ export * from './remote.js';
33
34
  export * from './realtime-supervisor.js';
34
35
  export * from './schema.js';
35
36
  export * from './sql-tag.js';
@@ -0,0 +1,76 @@
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 { type PushOperationResult, type PushResultFrame } from '@syncular/core';
6
+ import type { MutationInput } from './client.js';
7
+ import type { EncryptionConfig } from './encryption.js';
8
+ import { ClientSyncError } from './errors.js';
9
+ import { type ClientSchema } from './schema.js';
10
+ import type { SyncTransport } from './transport.js';
11
+ import type { RemoteOperationRealtimeConnector, RemoteOperationTransport } from './transport.js';
12
+ export interface SyncRemoteClientConfig {
13
+ /** Required only for ordinary row commits. */
14
+ readonly schema?: ClientSchema;
15
+ readonly clientId: string;
16
+ /** Required only for ordinary row commits. */
17
+ readonly transport?: SyncTransport;
18
+ readonly operations?: RemoteOperationTransport;
19
+ readonly operationRealtime?: RemoteOperationRealtimeConnector;
20
+ readonly encryption?: EncryptionConfig;
21
+ }
22
+ export interface RemoteCommitInput {
23
+ /** Stable caller-owned idempotency identity for this logical commit. */
24
+ readonly requestId: string;
25
+ readonly mutations: readonly MutationInput[];
26
+ }
27
+ /**
28
+ * Prepared bytes are the retry unit. Persist them when a process must retry
29
+ * across restart, especially when encryption uses randomized nonces.
30
+ */
31
+ export interface PreparedRemoteCommit {
32
+ readonly requestId: string;
33
+ readonly bytes: Uint8Array;
34
+ }
35
+ export interface RemoteCommitResult {
36
+ readonly requestId: string;
37
+ readonly status: PushResultFrame['status'];
38
+ readonly commitSeq?: number;
39
+ readonly results: readonly PushOperationResult[];
40
+ }
41
+ export interface RemoteQueryDescriptor<Row, Params = undefined> {
42
+ readonly id: string;
43
+ readonly mapRow: (row: Readonly<Record<string, unknown>>) => Row;
44
+ readonly __row?: Row;
45
+ readonly __params?: Params;
46
+ }
47
+ export interface RemoteQueryResult<Row> {
48
+ readonly rows: readonly Row[];
49
+ readonly maxCommitSeq: number;
50
+ }
51
+ export interface RemoteQueryWatchHandlers<Row> {
52
+ onSnapshot(result: RemoteQueryResult<Row>): void;
53
+ onError?(error: ClientSyncError): void;
54
+ }
55
+ export interface RemoteCommandDescriptor<Input = undefined> {
56
+ readonly id: string;
57
+ readonly __input?: Input;
58
+ }
59
+ export declare function remoteCommand<Input = undefined>(id: string): RemoteCommandDescriptor<Input>;
60
+ export interface RemoteCommandResult {
61
+ readonly requestId: string;
62
+ readonly status: 'applied' | 'cached' | 'rejected';
63
+ readonly commitSeq?: number;
64
+ readonly results: readonly unknown[];
65
+ }
66
+ export declare class SyncRemoteClient {
67
+ #private;
68
+ constructor(config: SyncRemoteClientConfig);
69
+ prepareCommit(input: RemoteCommitInput): Promise<PreparedRemoteCommit>;
70
+ sendCommit(prepared: PreparedRemoteCommit): Promise<RemoteCommitResult>;
71
+ commit(input: RemoteCommitInput): Promise<RemoteCommitResult>;
72
+ query<Row, Params = undefined>(descriptor: RemoteQueryDescriptor<Row, Params>, params?: Params): Promise<RemoteQueryResult<Row>>;
73
+ command<Input = undefined>(descriptor: RemoteCommandDescriptor<Input>, requestId: string, input?: Input): Promise<RemoteCommandResult>;
74
+ watch<Row, Params = undefined>(descriptor: RemoteQueryDescriptor<Row, Params>, params: Params, handlers: RemoteQueryWatchHandlers<Row>): Promise<() => void>;
75
+ close(): void;
76
+ }
package/dist/remote.js ADDED
@@ -0,0 +1,441 @@
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 { decodeMessage, decodeRemoteOperationResponse, decodeRemoteOperationRealtimeMessage, encodeMessage, encodeRemoteOperationRequest, encodeRemoteOperationRealtimeMessage, encodeRow, PROTOCOL_WIRE_VERSION, } from '@syncular/core';
6
+ import { encryptRowValues } from './encryption.js';
7
+ import { ClientSyncError } from './errors.js';
8
+ import { compileClientSchema, recordToRowValues, } from './schema.js';
9
+ export function remoteCommand(id) {
10
+ if (id.length === 0)
11
+ throw invalid('remote command id must be non-empty');
12
+ return { id };
13
+ }
14
+ function invalid(message) {
15
+ return new ClientSyncError('sync.invalid_request', message);
16
+ }
17
+ function operationResponse(bytes) {
18
+ let response;
19
+ try {
20
+ response = decodeRemoteOperationResponse(bytes);
21
+ }
22
+ catch {
23
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response is malformed');
24
+ }
25
+ if (typeof response !== 'object' ||
26
+ response === null ||
27
+ Array.isArray(response)) {
28
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response is malformed');
29
+ }
30
+ if (response.revision !== 1) {
31
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response has an unsupported revision');
32
+ }
33
+ if (response.kind === 'error' &&
34
+ (typeof response.code !== 'string' ||
35
+ typeof response.message !== 'string' ||
36
+ typeof response.retryable !== 'boolean')) {
37
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation error response is malformed');
38
+ }
39
+ if (response.kind === 'query' &&
40
+ (typeof response.operationId !== 'string' ||
41
+ !Array.isArray(response.rows) ||
42
+ response.rows.some((row) => typeof row !== 'object' || row === null || Array.isArray(row)) ||
43
+ !Number.isSafeInteger(response.maxCommitSeq) ||
44
+ response.maxCommitSeq < 0)) {
45
+ throw new ClientSyncError('client.invalid_host_response', 'remote query response is malformed');
46
+ }
47
+ if (response.kind === 'command' &&
48
+ (typeof response.operationId !== 'string' ||
49
+ typeof response.requestId !== 'string' ||
50
+ !['applied', 'cached', 'rejected'].includes(response.status) ||
51
+ !Array.isArray(response.results) ||
52
+ (response.commitSeq !== undefined &&
53
+ (!Number.isSafeInteger(response.commitSeq) || response.commitSeq < 1)))) {
54
+ throw new ClientSyncError('client.invalid_host_response', 'remote command response is malformed');
55
+ }
56
+ if (response.kind !== 'error' &&
57
+ response.kind !== 'query' &&
58
+ response.kind !== 'command') {
59
+ throw new ClientSyncError('client.invalid_host_response', 'remote operation response has an unknown kind');
60
+ }
61
+ return response;
62
+ }
63
+ export class SyncRemoteClient {
64
+ #schema;
65
+ #clientId;
66
+ #transport;
67
+ #operations;
68
+ #operationRealtime;
69
+ #operationSocket;
70
+ #operationSocketPromise;
71
+ #operationSocketGeneration = 0;
72
+ #watches = new Map();
73
+ #encryption;
74
+ constructor(config) {
75
+ if (config.clientId.length === 0) {
76
+ throw invalid('SyncRemoteClient clientId must be non-empty');
77
+ }
78
+ this.#schema =
79
+ config.schema === undefined
80
+ ? undefined
81
+ : compileClientSchema(config.schema);
82
+ this.#clientId = config.clientId;
83
+ this.#transport = config.transport;
84
+ this.#operations = config.operations;
85
+ this.#operationRealtime = config.operationRealtime;
86
+ this.#encryption = config.encryption;
87
+ }
88
+ async prepareCommit(input) {
89
+ const schema = this.#schema;
90
+ if (schema === undefined) {
91
+ throw new ClientSyncError('client.remote_schema_unconfigured', 'SyncRemoteClient needs a schema to prepare ordinary commits');
92
+ }
93
+ if (input.requestId.length === 0) {
94
+ throw invalid('remote commit requestId must be non-empty');
95
+ }
96
+ if (input.mutations.length === 0) {
97
+ throw new ClientSyncError('sync.empty_commit', 'a remote commit must carry at least one mutation (§6.1)');
98
+ }
99
+ const operations = [];
100
+ for (const mutation of input.mutations) {
101
+ const table = schema.tables.get(mutation.table);
102
+ if (table === undefined) {
103
+ throw invalid('remote commit targets an unknown table');
104
+ }
105
+ if (mutation.op === 'delete') {
106
+ if (mutation.rowId.length === 0) {
107
+ throw invalid('remote delete rowId must be non-empty');
108
+ }
109
+ operations.push({
110
+ table: mutation.table,
111
+ rowId: mutation.rowId,
112
+ op: 'delete',
113
+ ...(mutation.baseVersion !== undefined
114
+ ? { baseVersion: mutation.baseVersion }
115
+ : {}),
116
+ });
117
+ continue;
118
+ }
119
+ let values = recordToRowValues(table, mutation.values);
120
+ const rowId = values[table.primaryKeyIndex];
121
+ if (typeof rowId !== 'string' || rowId.length === 0) {
122
+ throw invalid('remote upsert requires a non-empty string primary key');
123
+ }
124
+ if (this.#encryption !== undefined && table.hasEncryptedColumns) {
125
+ values = await encryptRowValues(this.#encryption, table, rowId, values);
126
+ }
127
+ operations.push({
128
+ table: mutation.table,
129
+ rowId,
130
+ op: 'upsert',
131
+ ...(mutation.baseVersion !== undefined
132
+ ? { baseVersion: mutation.baseVersion }
133
+ : {}),
134
+ payload: encodeRow(table.columns, values),
135
+ });
136
+ }
137
+ return {
138
+ requestId: input.requestId,
139
+ bytes: encodeMessage({
140
+ wireVersion: PROTOCOL_WIRE_VERSION,
141
+ msgKind: 'request',
142
+ frames: [
143
+ {
144
+ type: 'REQ_HEADER',
145
+ clientId: this.#clientId,
146
+ schemaVersion: schema.version,
147
+ },
148
+ {
149
+ type: 'PUSH_COMMIT',
150
+ clientCommitId: input.requestId,
151
+ operations,
152
+ },
153
+ ],
154
+ }),
155
+ };
156
+ }
157
+ async sendCommit(prepared) {
158
+ if (this.#transport === undefined) {
159
+ throw new ClientSyncError('client.remote_sync_unconfigured', 'SyncRemoteClient has no sync transport for ordinary commits');
160
+ }
161
+ const response = decodeMessage(await this.#transport(prepared.bytes));
162
+ if (response.msgKind !== 'response') {
163
+ throw new ClientSyncError('client.invalid_host_response', 'remote commit transport returned a non-response SSP2 message');
164
+ }
165
+ const error = response.frames.find((frame) => frame.type === 'ERROR');
166
+ if (error?.type === 'ERROR') {
167
+ throw new ClientSyncError(error.code, error.message, error.retryable);
168
+ }
169
+ const result = response.frames.find((frame) => frame.type === 'PUSH_RESULT' &&
170
+ frame.clientCommitId === prepared.requestId);
171
+ if (result === undefined) {
172
+ throw new ClientSyncError('client.invalid_host_response', 'remote commit response carried no matching PUSH_RESULT');
173
+ }
174
+ const details = response.frames.find((frame) => frame.type === 'PUSH_RESULT_DETAILS' &&
175
+ frame.clientCommitId === prepared.requestId);
176
+ const detailsByIndex = new Map(details?.entries.map((entry) => [entry.opIndex, entry.details]) ?? []);
177
+ return {
178
+ requestId: prepared.requestId,
179
+ status: result.status,
180
+ ...(result.commitSeq !== undefined
181
+ ? { commitSeq: result.commitSeq }
182
+ : {}),
183
+ results: result.results.map((operation) => {
184
+ if (operation.status !== 'error')
185
+ return operation;
186
+ const operationDetails = detailsByIndex.get(operation.opIndex);
187
+ return operationDetails === undefined
188
+ ? operation
189
+ : { ...operation, details: operationDetails };
190
+ }),
191
+ };
192
+ }
193
+ async commit(input) {
194
+ return this.sendCommit(await this.prepareCommit(input));
195
+ }
196
+ async query(descriptor, params) {
197
+ if (this.#operations === undefined) {
198
+ throw new ClientSyncError('client.remote_operations_unconfigured', 'SyncRemoteClient has no remote operation transport');
199
+ }
200
+ const response = operationResponse(await this.#operations(encodeRemoteOperationRequest({
201
+ revision: 1,
202
+ kind: 'query',
203
+ clientId: this.#clientId,
204
+ operationId: descriptor.id,
205
+ params: params ?? null,
206
+ })));
207
+ if (response.kind === 'error') {
208
+ throw new ClientSyncError(response.code, response.message, response.retryable);
209
+ }
210
+ if (response.kind !== 'query' || response.operationId !== descriptor.id) {
211
+ throw new ClientSyncError('client.invalid_host_response', 'remote query returned a mismatched response');
212
+ }
213
+ try {
214
+ return {
215
+ rows: response.rows.map((row) => descriptor.mapRow(row)),
216
+ maxCommitSeq: response.maxCommitSeq,
217
+ };
218
+ }
219
+ catch {
220
+ throw new ClientSyncError('client.invalid_host_response', 'remote query row is malformed');
221
+ }
222
+ }
223
+ async command(descriptor, requestId, input) {
224
+ if (this.#operations === undefined) {
225
+ throw new ClientSyncError('client.remote_operations_unconfigured', 'SyncRemoteClient has no remote operation transport');
226
+ }
227
+ if (requestId.length === 0) {
228
+ throw invalid('remote command requestId must be non-empty');
229
+ }
230
+ const response = operationResponse(await this.#operations(encodeRemoteOperationRequest({
231
+ revision: 1,
232
+ kind: 'command',
233
+ clientId: this.#clientId,
234
+ operationId: descriptor.id,
235
+ requestId,
236
+ params: input ?? null,
237
+ })));
238
+ if (response.kind === 'error') {
239
+ throw new ClientSyncError(response.code, response.message, response.retryable);
240
+ }
241
+ if (response.kind !== 'command' ||
242
+ response.operationId !== descriptor.id ||
243
+ response.requestId !== requestId) {
244
+ throw new ClientSyncError('client.invalid_host_response', 'remote command returned a mismatched response');
245
+ }
246
+ return {
247
+ requestId,
248
+ status: response.status,
249
+ ...(response.commitSeq !== undefined
250
+ ? { commitSeq: response.commitSeq }
251
+ : {}),
252
+ results: response.results,
253
+ };
254
+ }
255
+ async watch(descriptor, params, handlers) {
256
+ if (this.#operationRealtime === undefined) {
257
+ throw new ClientSyncError('client.remote_realtime_unconfigured', 'SyncRemoteClient has no remote operation realtime connector');
258
+ }
259
+ const watchId = crypto.randomUUID();
260
+ this.#watches.set(watchId, {
261
+ operationId: descriptor.id,
262
+ mapRow: descriptor.mapRow,
263
+ handlers: handlers,
264
+ });
265
+ let socket;
266
+ try {
267
+ socket = await this.#operationRealtimeSocket();
268
+ }
269
+ catch (error) {
270
+ this.#watches.delete(watchId);
271
+ throw error;
272
+ }
273
+ if (!this.#watches.has(watchId)) {
274
+ throw new ClientSyncError('client.remote_realtime_cancelled', 'remote operation watch was cancelled before registration');
275
+ }
276
+ try {
277
+ socket.send(encodeRemoteOperationRealtimeMessage({
278
+ revision: 1,
279
+ kind: 'watch',
280
+ watchId,
281
+ clientId: this.#clientId,
282
+ operationId: descriptor.id,
283
+ params: params ?? null,
284
+ }));
285
+ }
286
+ catch {
287
+ const error = new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed while registering a watch', true);
288
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, error, true);
289
+ throw error;
290
+ }
291
+ return () => {
292
+ if (!this.#watches.delete(watchId))
293
+ return;
294
+ try {
295
+ this.#operationSocket?.send(encodeRemoteOperationRealtimeMessage({
296
+ revision: 1,
297
+ kind: 'unwatch',
298
+ watchId,
299
+ }));
300
+ }
301
+ catch {
302
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed while removing a watch', true), true);
303
+ }
304
+ };
305
+ }
306
+ async #operationRealtimeSocket() {
307
+ if (this.#operationSocket !== undefined)
308
+ return this.#operationSocket;
309
+ if (this.#operationSocketPromise !== undefined) {
310
+ return this.#operationSocketPromise;
311
+ }
312
+ const connector = this.#operationRealtime;
313
+ if (connector === undefined) {
314
+ throw new ClientSyncError('client.remote_realtime_unconfigured', 'SyncRemoteClient has no remote operation realtime connector');
315
+ }
316
+ const generation = this.#operationSocketGeneration;
317
+ const pending = Promise.resolve(connector({
318
+ onMessage: (bytes) => {
319
+ if (generation !== this.#operationSocketGeneration)
320
+ return;
321
+ let message;
322
+ try {
323
+ message = decodeRemoteOperationRealtimeMessage(bytes);
324
+ if (typeof message !== 'object' ||
325
+ message === null ||
326
+ message.revision !== 1 ||
327
+ (message.kind !== 'snapshot' && message.kind !== 'watch_error') ||
328
+ typeof message.watchId !== 'string') {
329
+ throw new Error('invalid remote operation realtime message');
330
+ }
331
+ }
332
+ catch {
333
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation realtime message is malformed'), true);
334
+ return;
335
+ }
336
+ const watch = this.#watches.get(message.watchId);
337
+ if (watch === undefined)
338
+ return;
339
+ if (message.kind === 'watch_error') {
340
+ if (typeof message.code !== 'string' ||
341
+ typeof message.message !== 'string' ||
342
+ typeof message.retryable !== 'boolean') {
343
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation watch error is malformed'), true);
344
+ return;
345
+ }
346
+ try {
347
+ watch.handlers.onError?.(new ClientSyncError(message.code, message.message, message.retryable));
348
+ }
349
+ catch {
350
+ // An observer cannot alter the connection lifecycle.
351
+ }
352
+ return;
353
+ }
354
+ if (message.operationId !== watch.operationId ||
355
+ !Array.isArray(message.rows) ||
356
+ message.rows.some((row) => typeof row !== 'object' || row === null || Array.isArray(row)) ||
357
+ !Number.isSafeInteger(message.maxCommitSeq) ||
358
+ message.maxCommitSeq < 0) {
359
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.invalid_host_response', 'remote operation watch snapshot is malformed'), true);
360
+ return;
361
+ }
362
+ let rows;
363
+ try {
364
+ rows = message.rows.map(watch.mapRow);
365
+ }
366
+ catch {
367
+ try {
368
+ watch.handlers.onError?.(new ClientSyncError('client.invalid_host_response', 'remote operation watch row is malformed'));
369
+ }
370
+ catch {
371
+ // An observer cannot alter the connection lifecycle.
372
+ }
373
+ return;
374
+ }
375
+ try {
376
+ watch.handlers.onSnapshot({
377
+ rows,
378
+ maxCommitSeq: message.maxCommitSeq,
379
+ });
380
+ }
381
+ catch {
382
+ // An observer cannot alter the connection lifecycle.
383
+ }
384
+ },
385
+ onClose: () => {
386
+ this.#disconnectOperationRealtime(generation, new ClientSyncError('client.remote_realtime_closed', 'remote operation realtime connection closed', true), false);
387
+ },
388
+ }))
389
+ .then((socket) => {
390
+ if (generation !== this.#operationSocketGeneration) {
391
+ try {
392
+ socket.close();
393
+ }
394
+ catch {
395
+ // The cancelled socket cannot affect the replacement generation.
396
+ }
397
+ throw new ClientSyncError('client.remote_realtime_cancelled', 'remote operation realtime connection was cancelled');
398
+ }
399
+ this.#operationSocket = socket;
400
+ return socket;
401
+ })
402
+ .finally(() => {
403
+ if (this.#operationSocketPromise === pending) {
404
+ this.#operationSocketPromise = undefined;
405
+ }
406
+ });
407
+ this.#operationSocketPromise = pending;
408
+ return pending;
409
+ }
410
+ #disconnectOperationRealtime(generation, error, closeSocket) {
411
+ if (generation !== this.#operationSocketGeneration)
412
+ return;
413
+ this.#operationSocketGeneration += 1;
414
+ const socket = this.#operationSocket;
415
+ this.#operationSocket = undefined;
416
+ this.#operationSocketPromise = undefined;
417
+ const watches = [...this.#watches.values()];
418
+ this.#watches.clear();
419
+ if (closeSocket) {
420
+ try {
421
+ socket?.close();
422
+ }
423
+ catch {
424
+ // Local state is already disconnected.
425
+ }
426
+ }
427
+ if (error === undefined)
428
+ return;
429
+ for (const watch of watches) {
430
+ try {
431
+ watch.handlers.onError?.(error);
432
+ }
433
+ catch {
434
+ // An observer cannot alter the connection lifecycle.
435
+ }
436
+ }
437
+ }
438
+ close() {
439
+ this.#disconnectOperationRealtime(this.#operationSocketGeneration, undefined, true);
440
+ }
441
+ }
@@ -6,6 +6,17 @@
6
6
  */
7
7
  /** One combined push+pull round trip: SSP2 request bytes → response bytes. */
8
8
  export type SyncTransport = (request: Uint8Array) => Promise<Uint8Array>;
9
+ /** One registered authoritative query or command request. */
10
+ export type RemoteOperationTransport = (request: Uint8Array) => Promise<Uint8Array>;
11
+ export interface RemoteOperationRealtimeHandlers {
12
+ onMessage(bytes: Uint8Array): void;
13
+ onClose?(): void;
14
+ }
15
+ export interface RemoteOperationRealtimeSocket {
16
+ send(bytes: Uint8Array): void;
17
+ close(): void;
18
+ }
19
+ export type RemoteOperationRealtimeConnector = (handlers: RemoteOperationRealtimeHandlers) => RemoteOperationRealtimeSocket | Promise<RemoteOperationRealtimeSocket>;
9
20
  export interface SegmentFetchRequest {
10
21
  readonly segmentId: string;
11
22
  readonly table: string;