@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/README.md +35 -41
- package/dist/http.d.ts +5 -1
- package/dist/http.js +68 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/node-database.d.ts +6 -29
- package/dist/node-database.js +7 -69
- package/dist/query-guard.d.ts +2 -2
- package/dist/query-guard.js +2 -2
- package/dist/remote.d.ts +76 -0
- package/dist/remote.js +441 -0
- package/dist/sqlite-bun.d.ts +2 -0
- package/dist/sqlite-bun.js +4 -0
- package/dist/sqlite-node.d.ts +2 -0
- package/dist/sqlite-node.js +4 -0
- package/dist/transport.d.ts +11 -0
- package/package.json +12 -14
- package/src/http.ts +99 -7
- package/src/index.ts +2 -1
- package/src/node-database.ts +11 -108
- package/src/query-guard.ts +2 -2
- package/src/remote.ts +724 -0
- package/src/sqlite-bun.ts +6 -0
- package/src/sqlite-node.ts +6 -0
- package/src/transport.ts +19 -0
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
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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.
|
|
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 --
|
|
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.
|
|
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.
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
* (`./
|
|
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';
|