@syncular/client 0.15.45 → 0.15.47

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/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
+ }
@@ -0,0 +1,2 @@
1
+ import type { ClientDatabase } from './database.js';
2
+ export declare function openSqliteDatabase(path?: string): ClientDatabase;
@@ -0,0 +1,4 @@
1
+ import { openBunDatabase } from './bun-database.js';
2
+ export function openSqliteDatabase(path = ':memory:') {
3
+ return openBunDatabase(path);
4
+ }
@@ -0,0 +1,2 @@
1
+ import type { ClientDatabase } from './database.js';
2
+ export declare function openSqliteDatabase(path?: string): ClientDatabase;
@@ -0,0 +1,4 @@
1
+ import { openNodeDatabase } from './node-database.js';
2
+ export function openSqliteDatabase(path = ':memory:') {
3
+ return openNodeDatabase(path);
4
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/client",
3
- "version": "0.15.45",
3
+ "version": "0.15.47",
4
4
  "description": "Syncular TypeScript client core — offline-first sync over SQLite (WASM/OPFS, Bun, Node)",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -58,6 +58,14 @@
58
58
  "default": "./dist/node-database.js"
59
59
  }
60
60
  },
61
+ "./sqlite": {
62
+ "bun": "./src/sqlite-bun.ts",
63
+ "node": {
64
+ "types": "./dist/sqlite-node.d.ts",
65
+ "default": "./dist/sqlite-node.js"
66
+ },
67
+ "types": "./dist/sqlite-node.d.ts"
68
+ },
61
69
  "./wasm": {
62
70
  "bun": "./src/wasm-database.ts",
63
71
  "browser": "./dist/wasm-database.js",
@@ -85,23 +93,13 @@
85
93
  "!dist/**/*.test.d.ts"
86
94
  ],
87
95
  "scripts": {
88
- "verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --external better-sqlite3 --outfile=./.verify-node.built.mjs && node ./.verify-node.built.mjs"
96
+ "verify:node": "bun build ./test/node-database/verify-node.mjs --target=node --outfile=./.verify-node.built.mjs && node ./.verify-node.built.mjs"
89
97
  },
90
98
  "dependencies": {
91
99
  "@sqlite.org/sqlite-wasm": "^3.53.0-build1",
92
- "@syncular/core": "0.15.45"
93
- },
94
- "peerDependencies": {
95
- "better-sqlite3": ">=11"
96
- },
97
- "peerDependenciesMeta": {
98
- "better-sqlite3": {
99
- "optional": true
100
- }
100
+ "@syncular/core": "0.15.47"
101
101
  },
102
102
  "devDependencies": {
103
- "@syncular/server": "0.15.45",
104
- "@types/better-sqlite3": "^7.6.13",
105
- "better-sqlite3": "^12.11.1"
103
+ "@syncular/server": "0.15.47"
106
104
  }
107
105
  }
package/src/http.ts CHANGED
@@ -9,6 +9,8 @@ import { SSP2_CONTENT_TYPE } from './content-type';
9
9
  import { ClientSyncError } from './errors';
10
10
  import type {
11
11
  RealtimeConnector,
12
+ RemoteOperationTransport,
13
+ RemoteOperationRealtimeConnector,
12
14
  SegmentDownloader,
13
15
  SyncTransport,
14
16
  } from './transport';
@@ -57,6 +59,78 @@ export function httpSyncTransport(
57
59
  };
58
60
  }
59
61
 
62
+ /** POST one registered authoritative operation to `<mount>/operations`. */
63
+ export function httpRemoteOperationTransport(
64
+ operationsUrl: string,
65
+ options?: HttpTransportOptions,
66
+ ): RemoteOperationTransport {
67
+ const doFetch = options?.fetch ?? fetch;
68
+ return async (request) => {
69
+ const response = await doFetch(operationsUrl, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/vnd.syncular.operations.v1+json',
73
+ ...options?.headers,
74
+ },
75
+ body: request.slice().buffer as ArrayBuffer,
76
+ });
77
+ if (!response.ok) await throwHttpError(response);
78
+ return new Uint8Array(await response.arrayBuffer());
79
+ };
80
+ }
81
+
82
+ /** WebSocket connector for registered query snapshots. */
83
+ export function webSocketRemoteOperationConnector(
84
+ realtimeUrl: string,
85
+ ): RemoteOperationRealtimeConnector {
86
+ return (handlers) =>
87
+ new Promise((resolve, reject) => {
88
+ const socket = new WebSocket(realtimeUrl);
89
+ let opened = false;
90
+ socket.binaryType = 'arraybuffer';
91
+ socket.onopen = () => {
92
+ opened = true;
93
+ resolve({
94
+ send: (bytes) => socket.send(bytes.slice().buffer as ArrayBuffer),
95
+ close: () => socket.close(),
96
+ });
97
+ };
98
+ socket.onmessage = (event) => {
99
+ if (event.data instanceof ArrayBuffer) {
100
+ handlers.onMessage(new Uint8Array(event.data));
101
+ }
102
+ };
103
+ socket.onerror = () => {
104
+ if (!opened) {
105
+ reject(
106
+ new ClientSyncError(
107
+ 'sync.transport_failed',
108
+ 'remote operation realtime socket failed to connect',
109
+ true,
110
+ ),
111
+ );
112
+ }
113
+ try {
114
+ socket.close();
115
+ } catch {
116
+ handlers.onClose?.();
117
+ }
118
+ };
119
+ socket.onclose = () => {
120
+ if (!opened) {
121
+ reject(
122
+ new ClientSyncError(
123
+ 'sync.transport_failed',
124
+ 'remote operation realtime socket closed while connecting',
125
+ true,
126
+ ),
127
+ );
128
+ }
129
+ handlers.onClose?.();
130
+ };
131
+ });
132
+ }
133
+
60
134
  /**
61
135
  * §5.5 direct endpoint with the `X-Syncular-Scopes` re-authorization
62
136
  * header, plus the §5.4 `fetchUrl` capability (advertises accept bit 3).
@@ -224,8 +298,10 @@ export function webSocketRealtimeConnector(
224
298
  return (handlers) =>
225
299
  new Promise((resolve, reject) => {
226
300
  const socket = new WebSocket(realtimeUrl);
301
+ let opened = false;
227
302
  socket.binaryType = 'arraybuffer';
228
303
  socket.onopen = () => {
304
+ opened = true;
229
305
  resolve({
230
306
  send: (text) => socket.send(text),
231
307
  sendBytes: (bytes) => {
@@ -239,15 +315,31 @@ export function webSocketRealtimeConnector(
239
315
  else handlers.onBinary(new Uint8Array(event.data as ArrayBuffer));
240
316
  };
241
317
  socket.onerror = () => {
242
- reject(
243
- new ClientSyncError(
244
- 'sync.transport_failed',
245
- 'realtime socket failed to connect',
246
- true,
247
- ),
248
- );
318
+ if (!opened) {
319
+ reject(
320
+ new ClientSyncError(
321
+ 'sync.transport_failed',
322
+ 'realtime socket failed to connect',
323
+ true,
324
+ ),
325
+ );
326
+ }
327
+ try {
328
+ socket.close();
329
+ } catch {
330
+ handlers.onClose?.();
331
+ }
249
332
  };
250
333
  socket.onclose = () => {
334
+ if (!opened) {
335
+ reject(
336
+ new ClientSyncError(
337
+ 'sync.transport_failed',
338
+ 'realtime socket closed while connecting',
339
+ true,
340
+ ),
341
+ );
342
+ }
251
343
  handlers.onClose?.();
252
344
  };
253
345
  });
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * SPEC.md is normative.
4
4
  *
5
5
  * Browser-safe root: database backends live behind subpath exports
6
- * (`./bun` for bun:sqlite tests, `./wasm` for sqlite-wasm + OPFS); the
6
+ * (`./sqlite` for Node or Bun, `./wasm` for sqlite-wasm + OPFS); the
7
7
  * worker-side bootstrap lives behind `./worker`. The main-thread handle
8
8
  * (`worker-host`) and the RPC protocol types are root exports — they
9
9
  * import no SQLite.
@@ -30,6 +30,7 @@ export * from './outbox';
30
30
  export * from './outcomes';
31
31
  export * from './query-guard';
32
32
  export * from './reactive-store';
33
+ export * from './remote';
33
34
  export * from './realtime-supervisor';
34
35
  export * from './schema';
35
36
  export * from './sql-tag';