@syncular/client 0.0.6-204 → 0.0.6-206
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/engine/SyncEngine.d.ts +3 -0
- package/dist/engine/SyncEngine.d.ts.map +1 -1
- package/dist/engine/SyncEngine.js +60 -5
- package/dist/engine/SyncEngine.js.map +1 -1
- package/dist/pull-engine.d.ts.map +1 -1
- package/dist/pull-engine.js +66 -138
- package/dist/pull-engine.js.map +1 -1
- package/package.json +3 -3
- package/src/engine/SyncEngine.test.ts +182 -5
- package/src/engine/SyncEngine.ts +80 -5
- package/src/pull-engine.test.ts +10 -10
- package/src/pull-engine.ts +102 -160
package/src/pull-engine.ts
CHANGED
|
@@ -13,6 +13,14 @@ import type {
|
|
|
13
13
|
SyncSubscriptionRequest,
|
|
14
14
|
SyncTransport,
|
|
15
15
|
} from '@syncular/core';
|
|
16
|
+
import {
|
|
17
|
+
bytesToReadableStream,
|
|
18
|
+
readAllBytesFromStream,
|
|
19
|
+
SYNC_SNAPSHOT_CHUNK_COMPRESSION,
|
|
20
|
+
SYNC_SNAPSHOT_CHUNK_ENCODING,
|
|
21
|
+
SYNC_SNAPSHOT_CHUNK_MAGIC,
|
|
22
|
+
sha256Hex,
|
|
23
|
+
} from '@syncular/core';
|
|
16
24
|
import { type Kysely, sql, type Transaction } from 'kysely';
|
|
17
25
|
import {
|
|
18
26
|
type ClientHandlerCollection,
|
|
@@ -30,9 +38,16 @@ import type { SyncClientDb, SyncSubscriptionStateTable } from './schema';
|
|
|
30
38
|
const jsonCache = new WeakMap<object, string>();
|
|
31
39
|
const jsonCacheStats = { hits: 0, misses: 0 };
|
|
32
40
|
const SNAPSHOT_APPLY_BATCH_ROWS = 500;
|
|
33
|
-
const SNAPSHOT_ROW_FRAME_MAGIC = new Uint8Array([0x53, 0x52, 0x46, 0x31]); // "SRF1"
|
|
34
41
|
const FRAME_LENGTH_BYTES = 4;
|
|
35
42
|
|
|
43
|
+
interface DigestStreamLike extends WritableStream<Uint8Array> {
|
|
44
|
+
readonly digest: Promise<ArrayBuffer>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CryptoWithDigestStream extends Crypto {
|
|
48
|
+
DigestStream?: new (algorithm: 'SHA-256') => DigestStreamLike;
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
function serializeJsonCached(obj: object): string {
|
|
37
52
|
if (obj === null || typeof obj !== 'object') {
|
|
38
53
|
return JSON.stringify(obj);
|
|
@@ -51,34 +66,6 @@ function serializeJsonCached(obj: object): string {
|
|
|
51
66
|
return serialized;
|
|
52
67
|
}
|
|
53
68
|
|
|
54
|
-
function isGzipBytes(bytes: Uint8Array): boolean {
|
|
55
|
-
return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function bytesToReadableStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
59
|
-
return new ReadableStream<Uint8Array>({
|
|
60
|
-
start(controller) {
|
|
61
|
-
controller.enqueue(bytes);
|
|
62
|
-
controller.close();
|
|
63
|
-
},
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
|
|
68
|
-
if (chunks.length === 1) {
|
|
69
|
-
return chunks[0] ?? new Uint8Array();
|
|
70
|
-
}
|
|
71
|
-
let total = 0;
|
|
72
|
-
for (const chunk of chunks) total += chunk.length;
|
|
73
|
-
const out = new Uint8Array(total);
|
|
74
|
-
let offset = 0;
|
|
75
|
-
for (const chunk of chunks) {
|
|
76
|
-
out.set(chunk, offset);
|
|
77
|
-
offset += chunk.length;
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
69
|
function appendBytes(base: Uint8Array, next: Uint8Array): Uint8Array {
|
|
83
70
|
if (base.length === 0) return next;
|
|
84
71
|
if (next.length === 0) return base;
|
|
@@ -88,62 +75,64 @@ function appendBytes(base: Uint8Array, next: Uint8Array): Uint8Array {
|
|
|
88
75
|
return out;
|
|
89
76
|
}
|
|
90
77
|
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return bytes;
|
|
78
|
+
function toHex(bytes: Uint8Array): string {
|
|
79
|
+
return Array.from(bytes)
|
|
80
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
81
|
+
.join('');
|
|
96
82
|
}
|
|
97
83
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (done) break;
|
|
108
|
-
if (!value || value.length === 0) continue;
|
|
109
|
-
prefetched.push(value);
|
|
110
|
-
prefetchedBytes += value.length;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const prefetchedCombined = concatBytes(prefetched);
|
|
114
|
-
const gzip = isGzipBytes(prefetchedCombined);
|
|
84
|
+
function getDigestStreamConstructor():
|
|
85
|
+
| (new (
|
|
86
|
+
algorithm: 'SHA-256'
|
|
87
|
+
) => DigestStreamLike)
|
|
88
|
+
| null {
|
|
89
|
+
if (typeof crypto === 'undefined') return null;
|
|
90
|
+
const cryptoWithDigestStream: CryptoWithDigestStream = crypto;
|
|
91
|
+
return cryptoWithDigestStream.DigestStream ?? null;
|
|
92
|
+
}
|
|
115
93
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
94
|
+
async function decodeSnapshotChunkStream(args: {
|
|
95
|
+
stream: ReadableStream<Uint8Array>;
|
|
96
|
+
compression: string;
|
|
97
|
+
encoding: string;
|
|
98
|
+
}): Promise<ReadableStream<Uint8Array>> {
|
|
99
|
+
if (args.encoding !== SYNC_SNAPSHOT_CHUNK_ENCODING) {
|
|
100
|
+
throw new Error(`Unexpected snapshot chunk encoding: ${args.encoding}`);
|
|
101
|
+
}
|
|
102
|
+
if (args.compression !== SYNC_SNAPSHOT_CHUNK_COMPRESSION) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`Unexpected snapshot chunk compression: ${args.compression}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (typeof DecompressionStream === 'undefined') {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'Snapshot chunk gzip decompression is not available in this runtime'
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return args.stream.pipeThrough(
|
|
113
|
+
new DecompressionStream('gzip') as TransformStream<Uint8Array, Uint8Array>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
137
116
|
|
|
138
|
-
|
|
117
|
+
async function computeSha256HexFromStream(
|
|
118
|
+
stream: ReadableStream<Uint8Array>,
|
|
119
|
+
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
if (sha256Override) {
|
|
122
|
+
const bytes = await readAllBytesFromStream(stream);
|
|
123
|
+
return sha256Override(bytes);
|
|
124
|
+
}
|
|
139
125
|
|
|
140
|
-
|
|
141
|
-
|
|
126
|
+
const DigestStream = getDigestStreamConstructor();
|
|
127
|
+
if (DigestStream) {
|
|
128
|
+
const digester = new DigestStream('SHA-256');
|
|
129
|
+
await stream.pipeTo(digester);
|
|
130
|
+
const digest = await digester.digest;
|
|
131
|
+
return toHex(new Uint8Array(digest));
|
|
142
132
|
}
|
|
143
133
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
);
|
|
134
|
+
const bytes = await readAllBytesFromStream(stream);
|
|
135
|
+
return sha256Hex(bytes);
|
|
147
136
|
}
|
|
148
137
|
|
|
149
138
|
async function* decodeSnapshotRowStreamBatches(
|
|
@@ -164,15 +153,15 @@ async function* decodeSnapshotRowStreamBatches(
|
|
|
164
153
|
pending = appendBytes(pending, value);
|
|
165
154
|
|
|
166
155
|
if (!headerValidated) {
|
|
167
|
-
if (pending.length <
|
|
156
|
+
if (pending.length < SYNC_SNAPSHOT_CHUNK_MAGIC.length) {
|
|
168
157
|
continue;
|
|
169
158
|
}
|
|
170
|
-
for (let index = 0; index <
|
|
171
|
-
if (pending[index] !==
|
|
159
|
+
for (let index = 0; index < SYNC_SNAPSHOT_CHUNK_MAGIC.length; index++) {
|
|
160
|
+
if (pending[index] !== SYNC_SNAPSHOT_CHUNK_MAGIC[index]) {
|
|
172
161
|
throw new Error('Unexpected snapshot chunk format');
|
|
173
162
|
}
|
|
174
163
|
}
|
|
175
|
-
pending = pending.subarray(
|
|
164
|
+
pending = pending.subarray(SYNC_SNAPSHOT_CHUNK_MAGIC.length);
|
|
176
165
|
headerValidated = true;
|
|
177
166
|
}
|
|
178
167
|
|
|
@@ -191,7 +180,11 @@ async function* decodeSnapshotRowStreamBatches(
|
|
|
191
180
|
FRAME_LENGTH_BYTES,
|
|
192
181
|
FRAME_LENGTH_BYTES + payloadLength
|
|
193
182
|
);
|
|
194
|
-
|
|
183
|
+
const parsed = JSON.parse(decoder.decode(payload));
|
|
184
|
+
if (!Array.isArray(parsed)) {
|
|
185
|
+
throw new Error('Snapshot chunk frame payload must be a JSON array');
|
|
186
|
+
}
|
|
187
|
+
rows.push(...parsed);
|
|
195
188
|
pending = pending.subarray(FRAME_LENGTH_BYTES + payloadLength);
|
|
196
189
|
|
|
197
190
|
if (rows.length >= batchSize) {
|
|
@@ -232,7 +225,11 @@ async function* decodeSnapshotRowStreamBatches(
|
|
|
232
225
|
FRAME_LENGTH_BYTES,
|
|
233
226
|
FRAME_LENGTH_BYTES + nextLength
|
|
234
227
|
);
|
|
235
|
-
|
|
228
|
+
const parsed = JSON.parse(decoder.decode(payload));
|
|
229
|
+
if (!Array.isArray(parsed)) {
|
|
230
|
+
throw new Error('Snapshot chunk frame payload must be a JSON array');
|
|
231
|
+
}
|
|
232
|
+
rows.push(...parsed);
|
|
236
233
|
pending = pending.subarray(FRAME_LENGTH_BYTES + nextLength);
|
|
237
234
|
if (rows.length >= batchSize) {
|
|
238
235
|
yield rows;
|
|
@@ -252,33 +249,6 @@ async function* decodeSnapshotRowStreamBatches(
|
|
|
252
249
|
}
|
|
253
250
|
}
|
|
254
251
|
|
|
255
|
-
async function computeSha256Hex(
|
|
256
|
-
bytes: Uint8Array,
|
|
257
|
-
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
258
|
-
): Promise<string> {
|
|
259
|
-
// Use injected implementation if provided (e.g. expo-crypto on React Native)
|
|
260
|
-
if (sha256Override) {
|
|
261
|
-
return sha256Override(bytes);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Use crypto.subtle if available (browsers, modern Node/Bun)
|
|
265
|
-
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
|
266
|
-
// Create a fresh ArrayBuffer to satisfy crypto.subtle's type requirements
|
|
267
|
-
const buffer = new ArrayBuffer(bytes.length);
|
|
268
|
-
new Uint8Array(buffer).set(bytes);
|
|
269
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
270
|
-
const hashArray = new Uint8Array(hashBuffer);
|
|
271
|
-
return Array.from(hashArray, (b) => b.toString(16).padStart(2, '0')).join(
|
|
272
|
-
''
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
throw new Error(
|
|
277
|
-
'No crypto implementation available for SHA-256. ' +
|
|
278
|
-
'Provide a sha256 function via options or ensure crypto.subtle is available.'
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
252
|
async function fetchSnapshotChunkStream(
|
|
283
253
|
transport: SyncTransport,
|
|
284
254
|
request: {
|
|
@@ -293,62 +263,32 @@ async function fetchSnapshotChunkStream(
|
|
|
293
263
|
return bytesToReadableStream(bytes);
|
|
294
264
|
}
|
|
295
265
|
|
|
296
|
-
async function readAllBytesFromStream(
|
|
297
|
-
stream: ReadableStream<Uint8Array>
|
|
298
|
-
): Promise<Uint8Array> {
|
|
299
|
-
const reader = stream.getReader();
|
|
300
|
-
const chunks: Uint8Array[] = [];
|
|
301
|
-
let totalLength = 0;
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
while (true) {
|
|
305
|
-
// eslint-disable-next-line no-await-in-loop
|
|
306
|
-
const { done, value } = await reader.read();
|
|
307
|
-
if (done) break;
|
|
308
|
-
if (!value) continue;
|
|
309
|
-
chunks.push(value);
|
|
310
|
-
totalLength += value.byteLength;
|
|
311
|
-
}
|
|
312
|
-
} finally {
|
|
313
|
-
reader.releaseLock();
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (chunks.length === 0) {
|
|
317
|
-
return new Uint8Array();
|
|
318
|
-
}
|
|
319
|
-
if (chunks.length === 1) {
|
|
320
|
-
return chunks[0]!;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const bytes = new Uint8Array(totalLength);
|
|
324
|
-
let offset = 0;
|
|
325
|
-
for (const chunk of chunks) {
|
|
326
|
-
bytes.set(chunk, offset);
|
|
327
|
-
offset += chunk.byteLength;
|
|
328
|
-
}
|
|
329
|
-
return bytes;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
266
|
async function materializeSnapshotChunkRows(
|
|
333
267
|
transport: SyncTransport,
|
|
334
268
|
request: {
|
|
335
269
|
chunkId: string;
|
|
336
270
|
scopeValues?: ScopeValues;
|
|
337
271
|
},
|
|
338
|
-
|
|
272
|
+
chunk: {
|
|
273
|
+
sha256: string;
|
|
274
|
+
compression: string;
|
|
275
|
+
encoding: string;
|
|
276
|
+
},
|
|
339
277
|
sha256Override?: (bytes: Uint8Array) => Promise<string>
|
|
340
278
|
): Promise<unknown[]> {
|
|
341
279
|
const rawStream = await fetchSnapshotChunkStream(transport, request);
|
|
342
|
-
const decodedStream = await
|
|
280
|
+
const decodedStream = await decodeSnapshotChunkStream({
|
|
281
|
+
stream: rawStream,
|
|
282
|
+
compression: chunk.compression,
|
|
283
|
+
encoding: chunk.encoding,
|
|
284
|
+
});
|
|
343
285
|
let streamForDecode = decodedStream;
|
|
344
286
|
let chunkHashPromise: Promise<string> | null = null;
|
|
345
287
|
|
|
346
|
-
if (
|
|
288
|
+
if (chunk.sha256) {
|
|
347
289
|
const [hashStream, decodeStream] = decodedStream.tee();
|
|
348
290
|
streamForDecode = decodeStream;
|
|
349
|
-
chunkHashPromise =
|
|
350
|
-
computeSha256Hex(bytes, sha256Override)
|
|
351
|
-
);
|
|
291
|
+
chunkHashPromise = computeSha256HexFromStream(hashStream, sha256Override);
|
|
352
292
|
}
|
|
353
293
|
|
|
354
294
|
const rows: unknown[] = [];
|
|
@@ -368,9 +308,9 @@ async function materializeSnapshotChunkRows(
|
|
|
368
308
|
if (chunkHashPromise) {
|
|
369
309
|
try {
|
|
370
310
|
const actualHash = await chunkHashPromise;
|
|
371
|
-
if (!materializeError && actualHash !==
|
|
311
|
+
if (!materializeError && actualHash !== chunk.sha256) {
|
|
372
312
|
materializeError = new Error(
|
|
373
|
-
`Snapshot chunk integrity check failed: expected sha256 ${
|
|
313
|
+
`Snapshot chunk integrity check failed: expected sha256 ${chunk.sha256}, got ${actualHash}`
|
|
374
314
|
);
|
|
375
315
|
}
|
|
376
316
|
} catch (hashError) {
|
|
@@ -416,7 +356,7 @@ async function materializeChunkedSnapshots(
|
|
|
416
356
|
chunkId: chunk.id,
|
|
417
357
|
scopeValues: sub.scopes,
|
|
418
358
|
},
|
|
419
|
-
chunk
|
|
359
|
+
chunk,
|
|
420
360
|
sha256Override
|
|
421
361
|
);
|
|
422
362
|
rows.push(...chunkRows);
|
|
@@ -462,16 +402,18 @@ async function applyChunkedSnapshot<DB extends SyncClientDb>(
|
|
|
462
402
|
chunkId: chunk.id,
|
|
463
403
|
scopeValues,
|
|
464
404
|
});
|
|
465
|
-
const decodedStream = await
|
|
405
|
+
const decodedStream = await decodeSnapshotChunkStream({
|
|
406
|
+
stream: rawStream,
|
|
407
|
+
compression: chunk.compression,
|
|
408
|
+
encoding: chunk.encoding,
|
|
409
|
+
});
|
|
466
410
|
let streamForDecode = decodedStream;
|
|
467
411
|
let chunkHashPromise: Promise<string> | null = null;
|
|
468
412
|
|
|
469
413
|
if (chunk.sha256) {
|
|
470
414
|
const [hashStream, decodeStream] = decodedStream.tee();
|
|
471
415
|
streamForDecode = decodeStream;
|
|
472
|
-
chunkHashPromise =
|
|
473
|
-
computeSha256Hex(bytes, sha256Override)
|
|
474
|
-
);
|
|
416
|
+
chunkHashPromise = computeSha256HexFromStream(hashStream, sha256Override);
|
|
475
417
|
}
|
|
476
418
|
|
|
477
419
|
const rowBatchIterator = decodeSnapshotRowStreamBatches(
|