@syncular/server 0.0.1-91 → 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.
@@ -22,6 +22,28 @@ export type DbExecutor<DB extends SyncCoreDb = SyncCoreDb> =
22
22
  */
23
23
  export type ServerSyncDialectName = string;
24
24
 
25
+ export interface IncrementalPullRowsArgs {
26
+ table: string;
27
+ scopes: ScopeValues;
28
+ cursor: number;
29
+ limitCommits: number;
30
+ partitionId?: string;
31
+ batchSize?: number;
32
+ }
33
+
34
+ export interface IncrementalPullRow {
35
+ commit_seq: number;
36
+ actor_id: string;
37
+ created_at: string;
38
+ change_id: number;
39
+ table: string;
40
+ row_id: string;
41
+ op: SyncOp;
42
+ row_json: unknown | null;
43
+ row_version: number | null;
44
+ scopes: StoredScopes;
45
+ }
46
+
25
47
  export interface ServerSyncDialect {
26
48
  readonly name: ServerSyncDialectName;
27
49
 
@@ -88,62 +110,15 @@ export interface ServerSyncDialect {
88
110
  ): Promise<SyncChangeRow[]>;
89
111
 
90
112
  /**
91
- * Optimized incremental pull for a subscription.
113
+ * Incremental pull iterator for a subscription.
92
114
  *
93
- * Returns change rows joined with commit metadata and filtered by
115
+ * Yields change rows joined with commit metadata and filtered by
94
116
  * the subscription's table and scope values.
95
117
  */
96
- readIncrementalPullRows<DB extends SyncCoreDb>(
97
- db: DbExecutor<DB>,
98
- args: {
99
- table: string;
100
- scopes: ScopeValues;
101
- cursor: number;
102
- limitCommits: number;
103
- partitionId?: string;
104
- }
105
- ): Promise<
106
- Array<{
107
- commit_seq: number;
108
- actor_id: string;
109
- created_at: string;
110
- change_id: number;
111
- table: string;
112
- row_id: string;
113
- op: SyncOp;
114
- row_json: unknown | null;
115
- row_version: number | null;
116
- scopes: StoredScopes;
117
- }>
118
- >;
119
-
120
- /**
121
- * Streaming incremental pull for large result sets.
122
- *
123
- * Yields changes one at a time instead of loading all into memory.
124
- * Use this when expecting large numbers of changes.
125
- */
126
- streamIncrementalPullRows?<DB extends SyncCoreDb>(
118
+ iterateIncrementalPullRows<DB extends SyncCoreDb>(
127
119
  db: DbExecutor<DB>,
128
- args: {
129
- table: string;
130
- scopes: ScopeValues;
131
- cursor: number;
132
- limitCommits: number;
133
- partitionId?: string;
134
- }
135
- ): AsyncGenerator<{
136
- commit_seq: number;
137
- actor_id: string;
138
- created_at: string;
139
- change_id: number;
140
- table: string;
141
- row_id: string;
142
- op: SyncOp;
143
- row_json: unknown | null;
144
- row_version: number | null;
145
- scopes: StoredScopes;
146
- }>;
120
+ args: IncrementalPullRowsArgs
121
+ ): AsyncGenerator<IncrementalPullRow>;
147
122
 
148
123
  /**
149
124
  * Optional compaction of the change log to reduce storage.
package/src/pull.ts CHANGED
@@ -37,6 +37,20 @@ async function compressSnapshotNdjson(ndjson: string): Promise<Uint8Array> {
37
37
  return new Uint8Array(compressed);
38
38
  }
39
39
 
40
+ function concatUint8Arrays(chunks: readonly Uint8Array[]): Uint8Array {
41
+ if (chunks.length === 0) return new Uint8Array();
42
+ if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
43
+
44
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
45
+ const out = new Uint8Array(total);
46
+ let offset = 0;
47
+ for (const chunk of chunks) {
48
+ out.set(chunk, offset);
49
+ offset += chunk.length;
50
+ }
51
+ return out;
52
+ }
53
+
40
54
  export interface PullResult {
41
55
  response: SyncPullResponse;
42
56
  /**
@@ -346,120 +360,189 @@ export async function pull<DB extends SyncCoreDb>(args: {
346
360
 
347
361
  const snapshots: SyncSnapshot[] = [];
348
362
  let nextState: SyncBootstrapState | null = effectiveState;
363
+ const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
364
+
365
+ interface SnapshotBundle {
366
+ table: string;
367
+ startCursor: string | null;
368
+ isFirstPage: boolean;
369
+ isLastPage: boolean;
370
+ pageCount: number;
371
+ ttlMs: number;
372
+ hash: ReturnType<typeof createHash>;
373
+ compressedParts: Uint8Array[];
374
+ }
349
375
 
350
- for (
351
- let pageIndex = 0;
352
- pageIndex < maxSnapshotPages;
353
- pageIndex++
354
- ) {
355
- if (!nextState) break;
356
-
357
- const nextTableName = nextState.tables[nextState.tableIndex];
358
- if (!nextTableName) {
359
- nextState = null;
360
- break;
361
- }
362
-
363
- const tableHandler = args.shapes.getOrThrow(nextTableName);
364
- const isFirstPage = nextState.rowCursor == null;
365
-
366
- const page = await tableHandler.snapshot(
367
- {
368
- db: trx,
369
- actorId: args.actorId,
370
- scopeValues: effectiveScopes,
371
- cursor: nextState.rowCursor,
372
- limit: limitSnapshotRows,
373
- },
374
- sub.params
375
- );
376
-
377
- const isLastPage = page.nextCursor == null;
378
-
379
- // Always use NDJSON+gzip for bootstrap snapshots
380
- const ttlMs =
381
- tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000; // 24h
376
+ const flushSnapshotBundle = async (
377
+ bundle: SnapshotBundle
378
+ ): Promise<void> => {
382
379
  const nowIso = new Date().toISOString();
380
+ const bundleRowLimit = Math.max(
381
+ 1,
382
+ limitSnapshotRows * bundle.pageCount
383
+ );
383
384
 
384
- // Use scope hash for caching
385
- const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
386
385
  const cached = await readSnapshotChunkRefByPageKey(trx, {
387
386
  partitionId,
388
387
  scopeKey: cacheKey,
389
- scope: nextTableName,
388
+ scope: bundle.table,
390
389
  asOfCommitSeq: effectiveState.asOfCommitSeq,
391
- rowCursor: nextState.rowCursor,
392
- rowLimit: limitSnapshotRows,
390
+ rowCursor: bundle.startCursor,
391
+ rowLimit: bundleRowLimit,
393
392
  encoding: 'ndjson',
394
393
  compression: 'gzip',
395
394
  nowIso,
396
395
  });
397
396
 
398
397
  let chunkRef = cached;
399
-
400
398
  if (!chunkRef) {
401
- const lines: string[] = [];
402
- for (const r of page.rows ?? []) {
403
- const s = JSON.stringify(r);
404
- lines.push(s === undefined ? 'null' : s);
405
- }
406
- const ndjson =
407
- lines.length > 0 ? `${lines.join('\n')}\n` : '';
408
- const gz = await compressSnapshotNdjson(ndjson);
409
- const sha256 = createHash('sha256')
410
- .update(ndjson)
411
- .digest('hex');
399
+ const sha256 = bundle.hash.digest('hex');
412
400
  const expiresAt = new Date(
413
- Date.now() + Math.max(1000, ttlMs)
401
+ Date.now() + Math.max(1000, bundle.ttlMs)
414
402
  ).toISOString();
403
+ const byteLength = bundle.compressedParts.reduce(
404
+ (sum, part) => sum + part.length,
405
+ 0
406
+ );
415
407
 
416
- // Use external chunk storage if available, otherwise fall back to inline
417
408
  if (args.chunkStorage) {
418
- chunkRef = await args.chunkStorage.storeChunk({
419
- partitionId,
420
- scopeKey: cacheKey,
421
- scope: nextTableName,
422
- asOfCommitSeq: effectiveState.asOfCommitSeq,
423
- rowCursor: nextState.rowCursor ?? null,
424
- rowLimit: limitSnapshotRows,
425
- encoding: 'ndjson',
426
- compression: 'gzip',
427
- sha256,
428
- body: gz,
429
- expiresAt,
430
- });
409
+ if (args.chunkStorage.storeChunkStream) {
410
+ chunkRef = await args.chunkStorage.storeChunkStream({
411
+ partitionId,
412
+ scopeKey: cacheKey,
413
+ scope: bundle.table,
414
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
415
+ rowCursor: bundle.startCursor,
416
+ rowLimit: bundleRowLimit,
417
+ encoding: 'ndjson',
418
+ compression: 'gzip',
419
+ sha256,
420
+ byteLength,
421
+ bodyStream: new ReadableStream<Uint8Array>({
422
+ start(controller) {
423
+ for (const part of bundle.compressedParts) {
424
+ controller.enqueue(part);
425
+ }
426
+ controller.close();
427
+ },
428
+ }),
429
+ expiresAt,
430
+ });
431
+ } else {
432
+ const body = concatUint8Arrays(bundle.compressedParts);
433
+ chunkRef = await args.chunkStorage.storeChunk({
434
+ partitionId,
435
+ scopeKey: cacheKey,
436
+ scope: bundle.table,
437
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
438
+ rowCursor: bundle.startCursor,
439
+ rowLimit: bundleRowLimit,
440
+ encoding: 'ndjson',
441
+ compression: 'gzip',
442
+ sha256,
443
+ body,
444
+ expiresAt,
445
+ });
446
+ }
431
447
  } else {
448
+ const body = concatUint8Arrays(bundle.compressedParts);
432
449
  const chunkId = randomUUID();
433
450
  chunkRef = await insertSnapshotChunk(trx, {
434
451
  chunkId,
435
452
  partitionId,
436
453
  scopeKey: cacheKey,
437
- scope: nextTableName,
454
+ scope: bundle.table,
438
455
  asOfCommitSeq: effectiveState.asOfCommitSeq,
439
- rowCursor: nextState.rowCursor,
440
- rowLimit: limitSnapshotRows,
456
+ rowCursor: bundle.startCursor,
457
+ rowLimit: bundleRowLimit,
441
458
  encoding: 'ndjson',
442
459
  compression: 'gzip',
443
460
  sha256,
444
- body: gz,
461
+ body,
445
462
  expiresAt,
446
463
  });
447
464
  }
448
465
  }
449
466
 
450
467
  snapshots.push({
451
- table: nextTableName,
468
+ table: bundle.table,
452
469
  rows: [],
453
470
  chunks: [chunkRef],
454
- isFirstPage,
455
- isLastPage,
471
+ isFirstPage: bundle.isFirstPage,
472
+ isLastPage: bundle.isLastPage,
456
473
  });
474
+ };
475
+
476
+ let activeBundle: SnapshotBundle | null = null;
477
+
478
+ for (
479
+ let pageIndex = 0;
480
+ pageIndex < maxSnapshotPages;
481
+ pageIndex++
482
+ ) {
483
+ if (!nextState) break;
484
+
485
+ const nextTableName = nextState.tables[nextState.tableIndex];
486
+ if (!nextTableName) {
487
+ if (activeBundle) {
488
+ activeBundle.isLastPage = true;
489
+ await flushSnapshotBundle(activeBundle);
490
+ activeBundle = null;
491
+ }
492
+ nextState = null;
493
+ break;
494
+ }
495
+
496
+ const tableHandler = args.shapes.getOrThrow(nextTableName);
497
+ if (!activeBundle || activeBundle.table !== nextTableName) {
498
+ if (activeBundle) {
499
+ await flushSnapshotBundle(activeBundle);
500
+ }
501
+ activeBundle = {
502
+ table: nextTableName,
503
+ startCursor: nextState.rowCursor,
504
+ isFirstPage: nextState.rowCursor == null,
505
+ isLastPage: false,
506
+ pageCount: 0,
507
+ ttlMs:
508
+ tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
509
+ hash: createHash('sha256'),
510
+ compressedParts: [],
511
+ };
512
+ }
513
+
514
+ const page = await tableHandler.snapshot(
515
+ {
516
+ db: trx,
517
+ actorId: args.actorId,
518
+ scopeValues: effectiveScopes,
519
+ cursor: nextState.rowCursor,
520
+ limit: limitSnapshotRows,
521
+ },
522
+ sub.params
523
+ );
524
+
525
+ const lines: string[] = [];
526
+ for (const r of page.rows ?? []) {
527
+ const s = JSON.stringify(r);
528
+ lines.push(s === undefined ? 'null' : s);
529
+ }
530
+ const ndjson = lines.length > 0 ? `${lines.join('\n')}\n` : '';
531
+ activeBundle.hash.update(ndjson);
532
+ activeBundle.compressedParts.push(
533
+ await compressSnapshotNdjson(ndjson)
534
+ );
535
+ activeBundle.pageCount += 1;
457
536
 
458
537
  if (page.nextCursor != null) {
459
538
  nextState = { ...nextState, rowCursor: page.nextCursor };
460
539
  continue;
461
540
  }
462
541
 
542
+ activeBundle.isLastPage = true;
543
+ await flushSnapshotBundle(activeBundle);
544
+ activeBundle = null;
545
+
463
546
  if (nextState.tableIndex + 1 < nextState.tables.length) {
464
547
  nextState = {
465
548
  ...nextState,
@@ -473,6 +556,10 @@ export async function pull<DB extends SyncCoreDb>(args: {
473
556
  break;
474
557
  }
475
558
 
559
+ if (activeBundle) {
560
+ await flushSnapshotBundle(activeBundle);
561
+ }
562
+
476
563
  subResponses.push({
477
564
  id: sub.id,
478
565
  status: 'active',
@@ -501,17 +588,6 @@ export async function pull<DB extends SyncCoreDb>(args: {
501
588
  ? scannedCommitSeqs[scannedCommitSeqs.length - 1]!
502
589
  : cursor;
503
590
 
504
- // Use streaming when available to reduce memory pressure for large pulls
505
- const pullRowStream = dialect.streamIncrementalPullRows
506
- ? dialect.streamIncrementalPullRows(trx, {
507
- partitionId,
508
- table: sub.shape,
509
- scopes: effectiveScopes,
510
- cursor,
511
- limitCommits,
512
- })
513
- : null;
514
-
515
591
  // Collect rows and compute nextCursor in a single pass
516
592
  const incrementalRows: Array<{
517
593
  commit_seq: number;
@@ -528,25 +604,15 @@ export async function pull<DB extends SyncCoreDb>(args: {
528
604
 
529
605
  let nextCursor = cursor;
530
606
 
531
- if (pullRowStream) {
532
- // Streaming path: process rows as they arrive
533
- for await (const row of pullRowStream) {
534
- incrementalRows.push(row);
535
- nextCursor = Math.max(nextCursor, row.commit_seq);
536
- }
537
- } else {
538
- // Non-streaming fallback: load all rows at once
539
- const rows = await dialect.readIncrementalPullRows(trx, {
540
- partitionId,
541
- table: sub.shape,
542
- scopes: effectiveScopes,
543
- cursor,
544
- limitCommits,
545
- });
546
- incrementalRows.push(...rows);
547
- for (const r of incrementalRows) {
548
- nextCursor = Math.max(nextCursor, r.commit_seq);
549
- }
607
+ for await (const row of dialect.iterateIncrementalPullRows(trx, {
608
+ partitionId,
609
+ table: sub.shape,
610
+ scopes: effectiveScopes,
611
+ cursor,
612
+ limitCommits,
613
+ })) {
614
+ incrementalRows.push(row);
615
+ nextCursor = Math.max(nextCursor, row.commit_seq);
550
616
  }
551
617
 
552
618
  nextCursor = Math.max(nextCursor, maxScannedCommitSeq);