@syncular/server 0.0.1-92 → 0.0.1-95

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.
@@ -37,7 +37,19 @@ export function createDbMetadataChunkStorage(
37
37
  body: Uint8Array;
38
38
  }
39
39
  ) => Promise<SyncSnapshotChunkRef>;
40
+ storeChunkStream: (
41
+ metadata: Omit<
42
+ SnapshotChunkMetadata,
43
+ 'chunkId' | 'byteLength' | 'blobHash'
44
+ > & {
45
+ bodyStream: ReadableStream<Uint8Array>;
46
+ byteLength: number;
47
+ }
48
+ ) => Promise<SyncSnapshotChunkRef>;
40
49
  readChunk: (chunkId: string) => Promise<Uint8Array | null>;
50
+ readChunkStream: (
51
+ chunkId: string
52
+ ) => Promise<ReadableStream<Uint8Array> | null>;
41
53
  findChunk: (
42
54
  pageKey: SnapshotChunkPageKey
43
55
  ) => Promise<SyncSnapshotChunkRef | null>;
@@ -50,6 +62,43 @@ export function createDbMetadataChunkStorage(
50
62
  return `sha256:${createHash('sha256').update(body).digest('hex')}`;
51
63
  }
52
64
 
65
+ function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
66
+ return new ReadableStream<Uint8Array>({
67
+ start(controller) {
68
+ controller.enqueue(bytes);
69
+ controller.close();
70
+ },
71
+ });
72
+ }
73
+
74
+ async function streamToBytes(
75
+ stream: ReadableStream<Uint8Array>
76
+ ): Promise<Uint8Array> {
77
+ const reader = stream.getReader();
78
+ try {
79
+ const chunks: Uint8Array[] = [];
80
+ let total = 0;
81
+
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ if (!value) continue;
86
+ chunks.push(value);
87
+ total += value.length;
88
+ }
89
+
90
+ const out = new Uint8Array(total);
91
+ let offset = 0;
92
+ for (const chunk of chunks) {
93
+ out.set(chunk, offset);
94
+ offset += chunk.length;
95
+ }
96
+ return out;
97
+ } finally {
98
+ reader.releaseLock();
99
+ }
100
+ }
101
+
53
102
  // Generate unique chunk ID
54
103
  function generateChunkId(): string {
55
104
  return `${chunkIdPrefix}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
@@ -107,6 +156,86 @@ export function createDbMetadataChunkStorage(
107
156
  };
108
157
  }
109
158
 
159
+ async function readBlobHash(chunkId: string): Promise<string | null> {
160
+ const row = await db
161
+ .selectFrom('sync_snapshot_chunks')
162
+ .select(['blob_hash'])
163
+ .where('chunk_id', '=', chunkId)
164
+ .executeTakeFirst();
165
+ return row?.blob_hash ?? null;
166
+ }
167
+
168
+ async function upsertChunkMetadata(
169
+ metadata: Omit<
170
+ SnapshotChunkMetadata,
171
+ 'chunkId' | 'byteLength' | 'blobHash'
172
+ >,
173
+ args: { blobHash: string; byteLength: number }
174
+ ): Promise<void> {
175
+ const chunkId = generateChunkId();
176
+ const now = new Date().toISOString();
177
+
178
+ await db
179
+ .insertInto('sync_snapshot_chunks')
180
+ .values({
181
+ chunk_id: chunkId,
182
+ partition_id: metadata.partitionId,
183
+ scope_key: metadata.scopeKey,
184
+ scope: metadata.scope,
185
+ as_of_commit_seq: metadata.asOfCommitSeq,
186
+ row_cursor: metadata.rowCursor ?? '',
187
+ row_limit: metadata.rowLimit,
188
+ encoding: metadata.encoding,
189
+ compression: metadata.compression,
190
+ sha256: metadata.sha256,
191
+ byte_length: args.byteLength,
192
+ blob_hash: args.blobHash,
193
+ expires_at: metadata.expiresAt,
194
+ created_at: now,
195
+ })
196
+ .onConflict((oc) =>
197
+ oc
198
+ .columns([
199
+ 'partition_id',
200
+ 'scope_key',
201
+ 'scope',
202
+ 'as_of_commit_seq',
203
+ 'row_cursor',
204
+ 'row_limit',
205
+ 'encoding',
206
+ 'compression',
207
+ ])
208
+ .doUpdateSet({
209
+ expires_at: metadata.expiresAt,
210
+ blob_hash: args.blobHash,
211
+ sha256: metadata.sha256,
212
+ byte_length: args.byteLength,
213
+ row_cursor: metadata.rowCursor ?? '',
214
+ })
215
+ )
216
+ .execute();
217
+ }
218
+
219
+ async function readChunkStreamById(
220
+ chunkId: string
221
+ ): Promise<ReadableStream<Uint8Array> | null> {
222
+ const blobHash = await readBlobHash(chunkId);
223
+ if (!blobHash) return null;
224
+
225
+ if (blobAdapter.getStream) {
226
+ return blobAdapter.getStream(blobHash);
227
+ }
228
+
229
+ if (blobAdapter.get) {
230
+ const bytes = await blobAdapter.get(blobHash);
231
+ return bytes ? bytesToStream(bytes) : null;
232
+ }
233
+
234
+ throw new Error(
235
+ `Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
236
+ );
237
+ }
238
+
110
239
  return {
111
240
  name: `db-metadata+${blobAdapter.name}`,
112
241
 
@@ -120,8 +249,6 @@ export function createDbMetadataChunkStorage(
120
249
  ): Promise<SyncSnapshotChunkRef> {
121
250
  const { body, ...metaWithoutBody } = metadata;
122
251
  const blobHash = computeBlobHash(body);
123
- const chunkId = generateChunkId();
124
- const now = new Date().toISOString();
125
252
 
126
253
  // Check if blob already exists (content-addressed dedup)
127
254
  const blobExists = await blobAdapter.exists(blobHash);
@@ -130,6 +257,8 @@ export function createDbMetadataChunkStorage(
130
257
  // Store body in blob adapter
131
258
  if (blobAdapter.put) {
132
259
  await blobAdapter.put(blobHash, body);
260
+ } else if (blobAdapter.putStream) {
261
+ await blobAdapter.putStream(blobHash, bytesToStream(body));
133
262
  } else {
134
263
  throw new Error(
135
264
  `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
@@ -137,46 +266,10 @@ export function createDbMetadataChunkStorage(
137
266
  }
138
267
  }
139
268
 
140
- // Upsert metadata in database
141
- await db
142
- .insertInto('sync_snapshot_chunks')
143
- .values({
144
- chunk_id: chunkId,
145
- partition_id: metaWithoutBody.partitionId,
146
- scope_key: metaWithoutBody.scopeKey,
147
- scope: metaWithoutBody.scope,
148
- as_of_commit_seq: metaWithoutBody.asOfCommitSeq,
149
- row_cursor: metaWithoutBody.rowCursor ?? '',
150
- row_limit: metaWithoutBody.rowLimit,
151
- encoding: metaWithoutBody.encoding,
152
- compression: metaWithoutBody.compression,
153
- sha256: metaWithoutBody.sha256,
154
- byte_length: body.length,
155
- blob_hash: blobHash,
156
- expires_at: metaWithoutBody.expiresAt,
157
- created_at: now,
158
- })
159
- .onConflict((oc) =>
160
- oc
161
- .columns([
162
- 'partition_id',
163
- 'scope_key',
164
- 'scope',
165
- 'as_of_commit_seq',
166
- 'row_cursor',
167
- 'row_limit',
168
- 'encoding',
169
- 'compression',
170
- ])
171
- .doUpdateSet({
172
- expires_at: metaWithoutBody.expiresAt,
173
- blob_hash: blobHash,
174
- sha256: metaWithoutBody.sha256,
175
- byte_length: body.length,
176
- row_cursor: metaWithoutBody.rowCursor ?? '',
177
- })
178
- )
179
- .execute();
269
+ await upsertChunkMetadata(metaWithoutBody, {
270
+ blobHash,
271
+ byteLength: body.length,
272
+ });
180
273
 
181
274
  const storedRef = await readStoredRef({
182
275
  partitionId: metaWithoutBody.partitionId,
@@ -197,24 +290,93 @@ export function createDbMetadataChunkStorage(
197
290
  return storedRef;
198
291
  },
199
292
 
200
- async readChunk(chunkId: string): Promise<Uint8Array | null> {
201
- // Get metadata to find blob hash
202
- const row = await db
203
- .selectFrom('sync_snapshot_chunks')
204
- .select(['blob_hash'])
205
- .where('chunk_id', '=', chunkId)
206
- .executeTakeFirst();
293
+ async storeChunkStream(
294
+ metadata: Omit<
295
+ SnapshotChunkMetadata,
296
+ 'chunkId' | 'byteLength' | 'blobHash'
297
+ > & {
298
+ bodyStream: ReadableStream<Uint8Array>;
299
+ byteLength: number;
300
+ }
301
+ ): Promise<SyncSnapshotChunkRef> {
302
+ const { bodyStream, byteLength, ...metaWithoutBody } = metadata;
303
+ const [uploadStream, hashStream] = bodyStream.tee();
207
304
 
208
- if (!row) return null;
305
+ const hasher = createHash('sha256');
306
+ let observedByteLength = 0;
307
+ const hashReader = hashStream.getReader();
308
+ try {
309
+ while (true) {
310
+ const { done, value } = await hashReader.read();
311
+ if (done) break;
312
+ if (!value) continue;
313
+ hasher.update(value);
314
+ observedByteLength += value.length;
315
+ }
316
+ } finally {
317
+ hashReader.releaseLock();
318
+ }
209
319
 
210
- // Read from blob adapter
211
- if (blobAdapter.get) {
212
- return blobAdapter.get(row.blob_hash);
320
+ if (observedByteLength !== byteLength) {
321
+ throw new Error(
322
+ `Snapshot chunk byte length mismatch: expected ${byteLength}, got ${observedByteLength}`
323
+ );
213
324
  }
214
325
 
215
- throw new Error(
216
- `Blob adapter ${blobAdapter.name} does not support direct get() for snapshot chunks`
217
- );
326
+ const blobHash = `sha256:${hasher.digest('hex')}`;
327
+
328
+ const blobExists = await blobAdapter.exists(blobHash);
329
+ if (!blobExists) {
330
+ if (blobAdapter.putStream) {
331
+ await blobAdapter.putStream(blobHash, uploadStream, {
332
+ byteLength: observedByteLength,
333
+ });
334
+ } else if (blobAdapter.put) {
335
+ const body = await streamToBytes(uploadStream);
336
+ await blobAdapter.put(blobHash, body);
337
+ } else {
338
+ throw new Error(
339
+ `Blob adapter ${blobAdapter.name} does not support direct put() for snapshot chunks`
340
+ );
341
+ }
342
+ } else {
343
+ await uploadStream.cancel();
344
+ }
345
+
346
+ await upsertChunkMetadata(metaWithoutBody, {
347
+ blobHash,
348
+ byteLength: observedByteLength,
349
+ });
350
+
351
+ const storedRef = await readStoredRef({
352
+ partitionId: metaWithoutBody.partitionId,
353
+ scopeKey: metaWithoutBody.scopeKey,
354
+ scope: metaWithoutBody.scope,
355
+ asOfCommitSeq: metaWithoutBody.asOfCommitSeq,
356
+ rowCursor: metaWithoutBody.rowCursor,
357
+ rowLimit: metaWithoutBody.rowLimit,
358
+ encoding: metaWithoutBody.encoding,
359
+ compression: metaWithoutBody.compression,
360
+ includeExpired: true,
361
+ });
362
+
363
+ if (!storedRef) {
364
+ throw new Error('Failed to read stored snapshot chunk reference');
365
+ }
366
+
367
+ return storedRef;
368
+ },
369
+
370
+ async readChunk(chunkId: string): Promise<Uint8Array | null> {
371
+ const stream = await readChunkStreamById(chunkId);
372
+ if (!stream) return null;
373
+ return streamToBytes(stream);
374
+ },
375
+
376
+ async readChunkStream(
377
+ chunkId: string
378
+ ): Promise<ReadableStream<Uint8Array> | null> {
379
+ return readChunkStreamById(chunkId);
218
380
  },
219
381
 
220
382
  async findChunk(
@@ -60,11 +60,31 @@ export interface SnapshotChunkStorage {
60
60
  }
61
61
  ): Promise<SyncSnapshotChunkRef>;
62
62
 
63
+ /**
64
+ * Store a chunk from a stream.
65
+ * Preferred for large payloads to avoid full buffering in memory.
66
+ */
67
+ storeChunkStream?(
68
+ metadata: Omit<
69
+ SnapshotChunkMetadata,
70
+ 'chunkId' | 'byteLength' | 'blobHash'
71
+ > & {
72
+ bodyStream: ReadableStream<Uint8Array>;
73
+ byteLength: number;
74
+ }
75
+ ): Promise<SyncSnapshotChunkRef>;
76
+
63
77
  /**
64
78
  * Read chunk body by chunk ID
65
79
  */
66
80
  readChunk(chunkId: string): Promise<Uint8Array | null>;
67
81
 
82
+ /**
83
+ * Read chunk body as a stream.
84
+ * Preferred for large payloads to avoid full buffering in memory.
85
+ */
86
+ readChunkStream?(chunkId: string): Promise<ReadableStream<Uint8Array> | null>;
87
+
68
88
  /**
69
89
  * Find existing chunk by page key
70
90
  */
@@ -32,7 +32,7 @@ export interface SnapshotChunkRow {
32
32
  compression: 'gzip';
33
33
  sha256: string;
34
34
  byteLength: number;
35
- body: Uint8Array;
35
+ body: Uint8Array | ReadableStream<Uint8Array>;
36
36
  expiresAt: string;
37
37
  }
38
38
 
@@ -203,7 +203,12 @@ export async function readSnapshotChunk<DB extends SyncCoreDb>(
203
203
  chunkId: string,
204
204
  options?: {
205
205
  /** External chunk storage for reading from S3/R2/etc */
206
- chunkStorage?: { readChunk(chunkId: string): Promise<Uint8Array | null> };
206
+ chunkStorage?: {
207
+ readChunk(chunkId: string): Promise<Uint8Array | null>;
208
+ readChunkStream?(
209
+ chunkId: string
210
+ ): Promise<ReadableStream<Uint8Array> | null>;
211
+ };
207
212
  }
208
213
  ): Promise<SnapshotChunkRow | null> {
209
214
  const rowResult = await sql<{
@@ -257,15 +262,32 @@ export async function readSnapshotChunk<DB extends SyncCoreDb>(
257
262
  }
258
263
 
259
264
  // Read body from external storage if available, otherwise use inline body
260
- let body: Uint8Array;
265
+ let body: Uint8Array | ReadableStream<Uint8Array>;
261
266
  if (options?.chunkStorage) {
262
- const externalBody = await options.chunkStorage.readChunk(chunkId);
263
- if (externalBody) {
264
- body = externalBody;
265
- } else if (row.body) {
266
- body = coerceChunkRow(row.body);
267
+ if (options.chunkStorage.readChunkStream) {
268
+ const externalBodyStream =
269
+ await options.chunkStorage.readChunkStream(chunkId);
270
+ if (externalBodyStream) {
271
+ body = externalBodyStream;
272
+ } else {
273
+ const externalBody = await options.chunkStorage.readChunk(chunkId);
274
+ if (externalBody) {
275
+ body = externalBody;
276
+ } else if (row.body) {
277
+ body = coerceChunkRow(row.body);
278
+ } else {
279
+ throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
280
+ }
281
+ }
267
282
  } else {
268
- throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
283
+ const externalBody = await options.chunkStorage.readChunk(chunkId);
284
+ if (externalBody) {
285
+ body = externalBody;
286
+ } else if (row.body) {
287
+ body = coerceChunkRow(row.body);
288
+ } else {
289
+ throw new Error(`Snapshot chunk body missing for chunk ${chunkId}`);
290
+ }
269
291
  }
270
292
  } else {
271
293
  body = coerceChunkRow(row.body);