@syncular/server 0.0.1-92 → 0.0.1-96

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
@@ -346,120 +346,182 @@ export async function pull<DB extends SyncCoreDb>(args: {
346
346
 
347
347
  const snapshots: SyncSnapshot[] = [];
348
348
  let nextState: SyncBootstrapState | null = effectiveState;
349
+ const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
350
+
351
+ interface SnapshotBundle {
352
+ table: string;
353
+ startCursor: string | null;
354
+ isFirstPage: boolean;
355
+ isLastPage: boolean;
356
+ pageCount: number;
357
+ ttlMs: number;
358
+ hash: ReturnType<typeof createHash>;
359
+ ndjsonParts: string[];
360
+ }
349
361
 
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
362
+ const flushSnapshotBundle = async (
363
+ bundle: SnapshotBundle
364
+ ): Promise<void> => {
382
365
  const nowIso = new Date().toISOString();
366
+ const bundleRowLimit = Math.max(
367
+ 1,
368
+ limitSnapshotRows * bundle.pageCount
369
+ );
383
370
 
384
- // Use scope hash for caching
385
- const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
386
371
  const cached = await readSnapshotChunkRefByPageKey(trx, {
387
372
  partitionId,
388
373
  scopeKey: cacheKey,
389
- scope: nextTableName,
374
+ scope: bundle.table,
390
375
  asOfCommitSeq: effectiveState.asOfCommitSeq,
391
- rowCursor: nextState.rowCursor,
392
- rowLimit: limitSnapshotRows,
376
+ rowCursor: bundle.startCursor,
377
+ rowLimit: bundleRowLimit,
393
378
  encoding: 'ndjson',
394
379
  compression: 'gzip',
395
380
  nowIso,
396
381
  });
397
382
 
398
383
  let chunkRef = cached;
399
-
400
384
  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');
385
+ const ndjson = bundle.ndjsonParts.join('');
386
+ const compressedBody = await compressSnapshotNdjson(ndjson);
387
+ const sha256 = bundle.hash.digest('hex');
412
388
  const expiresAt = new Date(
413
- Date.now() + Math.max(1000, ttlMs)
389
+ Date.now() + Math.max(1000, bundle.ttlMs)
414
390
  ).toISOString();
391
+ const byteLength = compressedBody.length;
415
392
 
416
- // Use external chunk storage if available, otherwise fall back to inline
417
393
  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
- });
394
+ if (args.chunkStorage.storeChunkStream) {
395
+ chunkRef = await args.chunkStorage.storeChunkStream({
396
+ partitionId,
397
+ scopeKey: cacheKey,
398
+ scope: bundle.table,
399
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
400
+ rowCursor: bundle.startCursor,
401
+ rowLimit: bundleRowLimit,
402
+ encoding: 'ndjson',
403
+ compression: 'gzip',
404
+ sha256,
405
+ byteLength,
406
+ bodyStream: new ReadableStream<Uint8Array>({
407
+ start(controller) {
408
+ controller.enqueue(compressedBody);
409
+ controller.close();
410
+ },
411
+ }),
412
+ expiresAt,
413
+ });
414
+ } else {
415
+ chunkRef = await args.chunkStorage.storeChunk({
416
+ partitionId,
417
+ scopeKey: cacheKey,
418
+ scope: bundle.table,
419
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
420
+ rowCursor: bundle.startCursor,
421
+ rowLimit: bundleRowLimit,
422
+ encoding: 'ndjson',
423
+ compression: 'gzip',
424
+ sha256,
425
+ body: compressedBody,
426
+ expiresAt,
427
+ });
428
+ }
431
429
  } else {
432
430
  const chunkId = randomUUID();
433
431
  chunkRef = await insertSnapshotChunk(trx, {
434
432
  chunkId,
435
433
  partitionId,
436
434
  scopeKey: cacheKey,
437
- scope: nextTableName,
435
+ scope: bundle.table,
438
436
  asOfCommitSeq: effectiveState.asOfCommitSeq,
439
- rowCursor: nextState.rowCursor,
440
- rowLimit: limitSnapshotRows,
437
+ rowCursor: bundle.startCursor,
438
+ rowLimit: bundleRowLimit,
441
439
  encoding: 'ndjson',
442
440
  compression: 'gzip',
443
441
  sha256,
444
- body: gz,
442
+ body: compressedBody,
445
443
  expiresAt,
446
444
  });
447
445
  }
448
446
  }
449
447
 
450
448
  snapshots.push({
451
- table: nextTableName,
449
+ table: bundle.table,
452
450
  rows: [],
453
451
  chunks: [chunkRef],
454
- isFirstPage,
455
- isLastPage,
452
+ isFirstPage: bundle.isFirstPage,
453
+ isLastPage: bundle.isLastPage,
456
454
  });
455
+ };
456
+
457
+ let activeBundle: SnapshotBundle | null = null;
458
+
459
+ for (
460
+ let pageIndex = 0;
461
+ pageIndex < maxSnapshotPages;
462
+ pageIndex++
463
+ ) {
464
+ if (!nextState) break;
465
+
466
+ const nextTableName = nextState.tables[nextState.tableIndex];
467
+ if (!nextTableName) {
468
+ if (activeBundle) {
469
+ activeBundle.isLastPage = true;
470
+ await flushSnapshotBundle(activeBundle);
471
+ activeBundle = null;
472
+ }
473
+ nextState = null;
474
+ break;
475
+ }
476
+
477
+ const tableHandler = args.shapes.getOrThrow(nextTableName);
478
+ if (!activeBundle || activeBundle.table !== nextTableName) {
479
+ if (activeBundle) {
480
+ await flushSnapshotBundle(activeBundle);
481
+ }
482
+ activeBundle = {
483
+ table: nextTableName,
484
+ startCursor: nextState.rowCursor,
485
+ isFirstPage: nextState.rowCursor == null,
486
+ isLastPage: false,
487
+ pageCount: 0,
488
+ ttlMs:
489
+ tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
490
+ hash: createHash('sha256'),
491
+ ndjsonParts: [],
492
+ };
493
+ }
494
+
495
+ const page = await tableHandler.snapshot(
496
+ {
497
+ db: trx,
498
+ actorId: args.actorId,
499
+ scopeValues: effectiveScopes,
500
+ cursor: nextState.rowCursor,
501
+ limit: limitSnapshotRows,
502
+ },
503
+ sub.params
504
+ );
505
+
506
+ const lines: string[] = [];
507
+ for (const r of page.rows ?? []) {
508
+ const s = JSON.stringify(r);
509
+ lines.push(s === undefined ? 'null' : s);
510
+ }
511
+ const ndjson = lines.length > 0 ? `${lines.join('\n')}\n` : '';
512
+ activeBundle.hash.update(ndjson);
513
+ activeBundle.ndjsonParts.push(ndjson);
514
+ activeBundle.pageCount += 1;
457
515
 
458
516
  if (page.nextCursor != null) {
459
517
  nextState = { ...nextState, rowCursor: page.nextCursor };
460
518
  continue;
461
519
  }
462
520
 
521
+ activeBundle.isLastPage = true;
522
+ await flushSnapshotBundle(activeBundle);
523
+ activeBundle = null;
524
+
463
525
  if (nextState.tableIndex + 1 < nextState.tables.length) {
464
526
  nextState = {
465
527
  ...nextState,
@@ -473,6 +535,10 @@ export async function pull<DB extends SyncCoreDb>(args: {
473
535
  break;
474
536
  }
475
537
 
538
+ if (activeBundle) {
539
+ await flushSnapshotBundle(activeBundle);
540
+ }
541
+
476
542
  subResponses.push({
477
543
  id: sub.id,
478
544
  status: 'active',
@@ -501,17 +567,6 @@ export async function pull<DB extends SyncCoreDb>(args: {
501
567
  ? scannedCommitSeqs[scannedCommitSeqs.length - 1]!
502
568
  : cursor;
503
569
 
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
570
  // Collect rows and compute nextCursor in a single pass
516
571
  const incrementalRows: Array<{
517
572
  commit_seq: number;
@@ -528,25 +583,15 @@ export async function pull<DB extends SyncCoreDb>(args: {
528
583
 
529
584
  let nextCursor = cursor;
530
585
 
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
- }
586
+ for await (const row of dialect.iterateIncrementalPullRows(trx, {
587
+ partitionId,
588
+ table: sub.shape,
589
+ scopes: effectiveScopes,
590
+ cursor,
591
+ limitCommits,
592
+ })) {
593
+ incrementalRows.push(row);
594
+ nextCursor = Math.max(nextCursor, row.commit_seq);
550
595
  }
551
596
 
552
597
  nextCursor = Math.max(nextCursor, maxScannedCommitSeq);