@syncular/client 0.15.44 → 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 +37 -5
- package/dist/browser-storage-persistence.d.ts +20 -0
- package/dist/browser-storage-persistence.js +34 -0
- package/dist/bun-database.d.ts +1 -1
- package/dist/bun-database.js +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +6 -6
- package/dist/database.d.ts +1 -1
- package/dist/devtools.d.ts +1 -1
- package/dist/http.d.ts +6 -2
- package/dist/http.js +68 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/invalidation.d.ts +1 -1
- package/dist/leader-lock.d.ts +2 -2
- package/dist/multi-tab.js +1 -1
- package/dist/naming.d.ts +1 -1
- package/dist/naming.js +1 -1
- package/dist/node-database.js +1 -1
- package/dist/query-guard.d.ts +1 -1
- package/dist/query-guard.js +1 -1
- package/dist/remote.d.ts +76 -0
- package/dist/remote.js +441 -0
- package/dist/schema.d.ts +3 -3
- package/dist/sql-tag.d.ts +1 -1
- package/dist/transport.d.ts +12 -1
- package/dist/transport.js +1 -1
- package/dist/wasm-database.d.ts +6 -4
- package/dist/wasm-database.js +13 -8
- package/dist/window.d.ts +1 -1
- package/dist/window.js +1 -1
- package/dist/worker-entry.js +1 -1
- package/dist/worker-host.d.ts +5 -5
- package/dist/worker-host.js +2 -2
- package/dist/worker-protocol.d.ts +3 -3
- package/dist/worker-protocol.js +1 -1
- package/package.json +3 -3
- package/src/browser-storage-persistence.ts +52 -0
- package/src/bun-database.ts +1 -1
- package/src/client.ts +6 -6
- package/src/database.ts +1 -1
- package/src/devtools.ts +1 -1
- package/src/http.ts +100 -8
- package/src/index.ts +4 -2
- package/src/invalidation.ts +1 -1
- package/src/leader-lock.ts +2 -2
- package/src/multi-tab.ts +1 -1
- package/src/naming.ts +1 -1
- package/src/node-database.ts +1 -1
- package/src/query-guard.ts +1 -1
- package/src/remote.ts +724 -0
- package/src/schema.ts +3 -3
- package/src/sql-tag.ts +1 -1
- package/src/transport.ts +20 -1
- package/src/wasm-database.ts +13 -8
- package/src/window.ts +1 -1
- package/src/worker-entry.ts +1 -1
- package/src/worker-host.ts +6 -6
- package/src/worker-protocol.ts +3 -3
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/schema.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client schema IR (SPEC.md §2.4, §3.1) — the same shape the server
|
|
3
|
-
* compiles
|
|
3
|
+
* compiles and is emitted by codegen. Drives local table
|
|
4
4
|
* DDL, the generated row codec, mutation helpers, and the §3.3 purge
|
|
5
5
|
* mapping (scope variable → local column).
|
|
6
6
|
*/
|
|
@@ -17,7 +17,7 @@ export interface ClientIndexSpec {
|
|
|
17
17
|
readonly columns: readonly string[];
|
|
18
18
|
readonly unique: boolean;
|
|
19
19
|
}
|
|
20
|
-
/** One client-local contentful FTS5 projection
|
|
20
|
+
/** One client-local contentful FTS5 projection. */
|
|
21
21
|
export interface ClientFtsIndexSpec {
|
|
22
22
|
readonly name: string;
|
|
23
23
|
readonly columns: readonly string[];
|
|
@@ -58,7 +58,7 @@ export interface CompiledClientTable {
|
|
|
58
58
|
/**
|
|
59
59
|
* Scope variable → the pattern's literal prefix (§3.1). A stored-scope
|
|
60
60
|
* value `v` for this variable has scope key `prefix:v` — the invalidation
|
|
61
|
-
* vocabulary
|
|
61
|
+
* vocabulary and the delta-routing key.
|
|
62
62
|
*/
|
|
63
63
|
readonly scopePrefixByVariable: ReadonlyMap<string, string>;
|
|
64
64
|
/** Local secondary indexes to create on the mirror table (declaration
|
package/dist/sql-tag.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The `sql` tagged template — the raw tier's composition helper
|
|
3
|
-
*
|
|
3
|
+
* Structural injection safety: an interpolated
|
|
4
4
|
* value can only ever become a `?` bind parameter; SQL text can only enter
|
|
5
5
|
* through the literal template, `sql.ident()` (allowlist-gated) or a loud
|
|
6
6
|
* `sql.raw()`. This helper is deliberately dumb plumbing and stays that
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transport seams
|
|
2
|
+
* Transport seams: request/response bytes, segment download,
|
|
3
3
|
* and the realtime attach surface matching §8's client side. Tests use
|
|
4
4
|
* loopback implementations that call the server library directly — the
|
|
5
5
|
* loopback doctrine; HTTP/WebSocket bindings live in `./http`.
|
|
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/dist/transport.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transport seams
|
|
2
|
+
* Transport seams: request/response bytes, segment download,
|
|
3
3
|
* and the realtime attach surface matching §8's client side. Tests use
|
|
4
4
|
* loopback implementations that call the server library directly — the
|
|
5
5
|
* loopback doctrine; HTTP/WebSocket bindings live in `./http`.
|
package/dist/wasm-database.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ClientDatabase } from './database.js';
|
|
|
3
3
|
* EXPLICIT ephemeral mode: an in-memory sqlite-wasm database. For tests,
|
|
4
4
|
* demos and SSR only — nothing persists. The persistent mode is
|
|
5
5
|
* `openPersistentWasmDatabase` inside a worker; there is no fallback from
|
|
6
|
-
* one to the other
|
|
6
|
+
* one to the other.
|
|
7
7
|
*/
|
|
8
8
|
export declare function openWasmDatabase(): Promise<ClientDatabase>;
|
|
9
9
|
export interface PersistentWasmDatabaseOptions {
|
|
@@ -18,13 +18,15 @@ export interface PersistentWasmDatabaseOptions {
|
|
|
18
18
|
readonly initialCapacity?: number;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* THE persistent browser mode: a named database on OPFS via the
|
|
21
|
+
* THE reload-persistent browser mode: a named database on OPFS via the
|
|
22
22
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
23
23
|
* it (it uses `FileSystemSyncAccessHandle`, no `Atomics.wait`, and could
|
|
24
24
|
* technically run on the main thread), but because the persistent mode IS
|
|
25
|
-
* whole-core-in-a-worker
|
|
25
|
+
* whole-core-in-a-worker and
|
|
26
26
|
* this factory enforces that decision. No COOP/COEP headers required.
|
|
27
27
|
*
|
|
28
|
-
* Support floor: no OPFS → a loud `ClientSyncError`, never a fallback.
|
|
28
|
+
* Support floor: no OPFS → a loud `ClientSyncError`, never a fallback. This
|
|
29
|
+
* factory cannot request eviction-resistant origin storage because that API
|
|
30
|
+
* belongs to the page's Window context.
|
|
29
31
|
*/
|
|
30
32
|
export declare function openPersistentWasmDatabase(name: string, options?: PersistentWasmDatabaseOptions): Promise<ClientDatabase>;
|
package/dist/wasm-database.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ClientDatabase` on @sqlite.org/sqlite-wasm
|
|
3
|
-
*
|
|
2
|
+
* `ClientDatabase` on @sqlite.org/sqlite-wasm. Two modes, no ladder between
|
|
3
|
+
* them:
|
|
4
4
|
*
|
|
5
|
-
* - `openPersistentWasmDatabase(name)` — THE persistent browser mode:
|
|
5
|
+
* - `openPersistentWasmDatabase(name)` — THE reload-persistent browser mode:
|
|
6
6
|
* OPFS via the `opfs-sahpool` VFS, restricted to Web Worker contexts
|
|
7
7
|
* because the whole client core runs in a worker by design. SAHPool
|
|
8
8
|
* needs **no COOP/COEP headers and no SharedArrayBuffer** (it is built
|
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* proxy — the COOP/COEP requirement documented by sqlite-wasm applies
|
|
11
11
|
* only to `oo1.OpfsDb`, which this binding no longer uses). Browsers
|
|
12
12
|
* without OPFS are unsupported (support floor ~2023+): the factory
|
|
13
|
-
* fails loud. Never IndexedDB, never a silent in-memory fallback.
|
|
13
|
+
* fails loud. Never IndexedDB, never a silent in-memory fallback. The
|
|
14
|
+
* browser's separate origin-eviction policy is exposed from the root package
|
|
15
|
+
* by `checkBrowserStoragePersistence` and
|
|
16
|
+
* `requestBrowserStoragePersistence`.
|
|
14
17
|
* - `openWasmDatabase()` — EXPLICIT ephemeral: an in-memory database for
|
|
15
18
|
* tests, demos and SSR. Nothing survives a reload, on purpose.
|
|
16
19
|
*
|
|
@@ -106,7 +109,7 @@ function initSqlite3() {
|
|
|
106
109
|
* EXPLICIT ephemeral mode: an in-memory sqlite-wasm database. For tests,
|
|
107
110
|
* demos and SSR only — nothing persists. The persistent mode is
|
|
108
111
|
* `openPersistentWasmDatabase` inside a worker; there is no fallback from
|
|
109
|
-
* one to the other
|
|
112
|
+
* one to the other.
|
|
110
113
|
*/
|
|
111
114
|
export async function openWasmDatabase() {
|
|
112
115
|
const sqlite3 = await initSqlite3();
|
|
@@ -133,14 +136,16 @@ function opfsSahPoolError(error, directory) {
|
|
|
133
136
|
`or rename the directory. Underlying error: ${detail}`, true);
|
|
134
137
|
}
|
|
135
138
|
/**
|
|
136
|
-
* THE persistent browser mode: a named database on OPFS via the
|
|
139
|
+
* THE reload-persistent browser mode: a named database on OPFS via the
|
|
137
140
|
* `opfs-sahpool` VFS. Worker-context only — not because SAHPool requires
|
|
138
141
|
* it (it uses `FileSystemSyncAccessHandle`, no `Atomics.wait`, and could
|
|
139
142
|
* technically run on the main thread), but because the persistent mode IS
|
|
140
|
-
* whole-core-in-a-worker
|
|
143
|
+
* whole-core-in-a-worker and
|
|
141
144
|
* this factory enforces that decision. No COOP/COEP headers required.
|
|
142
145
|
*
|
|
143
|
-
* Support floor: no OPFS → a loud `ClientSyncError`, never a fallback.
|
|
146
|
+
* Support floor: no OPFS → a loud `ClientSyncError`, never a fallback. This
|
|
147
|
+
* factory cannot request eviction-resistant origin storage because that API
|
|
148
|
+
* belongs to the page's Window context.
|
|
144
149
|
*/
|
|
145
150
|
export async function openPersistentWasmDatabase(name, options) {
|
|
146
151
|
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
package/dist/window.d.ts
CHANGED
package/dist/window.js
CHANGED
package/dist/worker-entry.js
CHANGED
package/dist/worker-host.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Main-thread side of the worker mode
|
|
3
|
-
* multi-tab topology
|
|
2
|
+
* Main-thread side of the worker mode and the
|
|
3
|
+
* multi-tab topology.
|
|
4
4
|
*
|
|
5
5
|
* `createSyncClientHandle` acquires the Web Locks leader lock and, when it
|
|
6
6
|
* wins, spawns the worker running the WHOLE core — so exactly one core runs
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* holding the lock). The returned {@link SyncClientHandle} is a thin, fully
|
|
9
9
|
* async proxy over the `worker-protocol` RPC.
|
|
10
10
|
*
|
|
11
|
-
* Multi-tab is the DEFAULT
|
|
11
|
+
* Multi-tab is the DEFAULT. The follower path is
|
|
12
12
|
* conformance-covered): a tab that LOSES the election becomes a FOLLOWER
|
|
13
13
|
* (`role === 'follower'`) that proxies every call to the leader tab over a
|
|
14
14
|
* BroadcastChannel (see `multi-tab.ts`). When the leader tab closes, its
|
|
@@ -86,7 +86,7 @@ export interface SyncClientHandleConfig {
|
|
|
86
86
|
/** Shared by default; isolated derives the database/lock/channel tuple. */
|
|
87
87
|
readonly replica?: BrowserReplicaMode;
|
|
88
88
|
/**
|
|
89
|
-
* Multi-tab followers
|
|
89
|
+
* Multi-tab followers. On by default: a tab that loses the
|
|
90
90
|
* leader election becomes a FOLLOWER that proxies to the leader over a
|
|
91
91
|
* BroadcastChannel, and contests + promotes when the leader closes. Set
|
|
92
92
|
* false for the single-tab contract — the loser is a dead
|
|
@@ -172,7 +172,7 @@ export declare class SyncClientHandle {
|
|
|
172
172
|
/** @internal — dispatch a worker/relayed event to handle-local listeners. */
|
|
173
173
|
__dispatchEvent(event: SyncWorkerEvent): void;
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
175
|
+
* Subscribe to fine-grained invalidation. The identical
|
|
176
176
|
* surface as `SyncClient.onInvalidate`, so React bindings target one
|
|
177
177
|
* interface across direct, worker-leader, and follower modes. Returns an
|
|
178
178
|
* unsubscribe function.
|
package/dist/worker-host.js
CHANGED
|
@@ -98,7 +98,7 @@ export class SyncClientHandle {
|
|
|
98
98
|
this.#diagnostics = internals.diagnostics;
|
|
99
99
|
this.#roleListeners = internals.roleListeners ?? new Set();
|
|
100
100
|
this.#leadershipListeners = internals.leadershipListeners ?? new Set();
|
|
101
|
-
//
|
|
101
|
+
// Console introspection is a no-op outside a dev page.
|
|
102
102
|
this.#devtoolsUnregister = registerDevtools({
|
|
103
103
|
kind: 'handle',
|
|
104
104
|
ref: this,
|
|
@@ -181,7 +181,7 @@ export class SyncClientHandle {
|
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
183
|
/**
|
|
184
|
-
*
|
|
184
|
+
* Subscribe to fine-grained invalidation. The identical
|
|
185
185
|
* surface as `SyncClient.onInvalidate`, so React bindings target one
|
|
186
186
|
* interface across direct, worker-leader, and follower modes. Returns an
|
|
187
187
|
* unsubscribe function.
|