@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.
@@ -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 toOwnedUint8Array(chunk: Uint8Array): Uint8Array<ArrayBuffer> {
92
- const out = new ArrayBuffer(chunk.byteLength);
93
- const bytes = new Uint8Array(out);
94
- bytes.set(chunk);
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
- async function maybeGunzipStream(
99
- stream: ReadableStream<Uint8Array>
100
- ): Promise<ReadableStream<Uint8Array>> {
101
- const reader = stream.getReader();
102
- const prefetched: Uint8Array[] = [];
103
- let prefetchedBytes = 0;
104
-
105
- while (prefetchedBytes < 2) {
106
- const { done, value } = await reader.read();
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
- const replayStream = new ReadableStream<Uint8Array<ArrayBuffer>>({
117
- start(controller) {
118
- if (prefetchedCombined.length > 0) {
119
- controller.enqueue(toOwnedUint8Array(prefetchedCombined));
120
- }
121
- },
122
- async pull(controller) {
123
- const { done, value } = await reader.read();
124
- if (done) {
125
- controller.close();
126
- reader.releaseLock();
127
- return;
128
- }
129
- if (!value || value.length === 0) return;
130
- controller.enqueue(toOwnedUint8Array(value));
131
- },
132
- async cancel(reason) {
133
- await reader.cancel(reason);
134
- reader.releaseLock();
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
- if (!gzip) return replayStream;
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
- if (typeof DecompressionStream !== 'undefined') {
141
- return replayStream.pipeThrough(new DecompressionStream('gzip'));
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
- throw new Error(
145
- 'Snapshot chunk appears gzip-compressed but gzip decompression is not available in this runtime'
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 < SNAPSHOT_ROW_FRAME_MAGIC.length) {
156
+ if (pending.length < SYNC_SNAPSHOT_CHUNK_MAGIC.length) {
168
157
  continue;
169
158
  }
170
- for (let index = 0; index < SNAPSHOT_ROW_FRAME_MAGIC.length; index++) {
171
- if (pending[index] !== SNAPSHOT_ROW_FRAME_MAGIC[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(SNAPSHOT_ROW_FRAME_MAGIC.length);
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
- rows.push(JSON.parse(decoder.decode(payload)));
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
- rows.push(JSON.parse(decoder.decode(payload)));
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
- expectedHash: string | undefined,
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 maybeGunzipStream(rawStream);
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 (expectedHash) {
288
+ if (chunk.sha256) {
347
289
  const [hashStream, decodeStream] = decodedStream.tee();
348
290
  streamForDecode = decodeStream;
349
- chunkHashPromise = readAllBytesFromStream(hashStream).then((bytes) =>
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 !== expectedHash) {
311
+ if (!materializeError && actualHash !== chunk.sha256) {
372
312
  materializeError = new Error(
373
- `Snapshot chunk integrity check failed: expected sha256 ${expectedHash}, got ${actualHash}`
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.sha256,
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 maybeGunzipStream(rawStream);
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 = readAllBytesFromStream(hashStream).then((bytes) =>
473
- computeSha256Hex(bytes, sha256Override)
474
- );
416
+ chunkHashPromise = computeSha256HexFromStream(hashStream, sha256Override);
475
417
  }
476
418
 
477
419
  const rowBatchIterator = decodeSnapshotRowStreamBatches(