@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.
- package/dist/dialect/base.d.ts +5 -21
- package/dist/dialect/base.d.ts.map +1 -1
- package/dist/dialect/base.js +33 -1
- package/dist/dialect/base.js.map +1 -1
- package/dist/dialect/types.d.ts +23 -44
- package/dist/dialect/types.d.ts.map +1 -1
- package/dist/pull.d.ts.map +1 -1
- package/dist/pull.js +132 -87
- package/dist/pull.js.map +1 -1
- package/dist/snapshot-chunks/adapters/s3.d.ts +11 -0
- package/dist/snapshot-chunks/adapters/s3.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.d.ts +5 -0
- package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
- package/dist/snapshot-chunks/db-metadata.js +168 -49
- package/dist/snapshot-chunks/db-metadata.js.map +1 -1
- package/dist/snapshot-chunks/types.d.ts +13 -0
- package/dist/snapshot-chunks/types.d.ts.map +1 -1
- package/dist/snapshot-chunks.d.ts +2 -1
- package/dist/snapshot-chunks.d.ts.map +1 -1
- package/dist/snapshot-chunks.js +27 -7
- package/dist/snapshot-chunks.js.map +1 -1
- package/package.json +1 -1
- package/src/dialect/base.ts +55 -25
- package/src/dialect/types.ts +27 -52
- package/src/pull.ts +167 -101
- package/src/snapshot-chunks/db-metadata.ts +218 -56
- package/src/snapshot-chunks/types.ts +20 -0
- package/src/snapshot-chunks.ts +31 -9
package/src/dialect/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
113
|
+
* Incremental pull iterator for a subscription.
|
|
92
114
|
*
|
|
93
|
-
*
|
|
115
|
+
* Yields change rows joined with commit metadata and filtered by
|
|
94
116
|
* the subscription's table and scope values.
|
|
95
117
|
*/
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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:
|
|
388
|
+
scope: bundle.table,
|
|
390
389
|
asOfCommitSeq: effectiveState.asOfCommitSeq,
|
|
391
|
-
rowCursor:
|
|
392
|
-
rowLimit:
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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:
|
|
454
|
+
scope: bundle.table,
|
|
438
455
|
asOfCommitSeq: effectiveState.asOfCommitSeq,
|
|
439
|
-
rowCursor:
|
|
440
|
-
rowLimit:
|
|
456
|
+
rowCursor: bundle.startCursor,
|
|
457
|
+
rowLimit: bundleRowLimit,
|
|
441
458
|
encoding: 'ndjson',
|
|
442
459
|
compression: 'gzip',
|
|
443
460
|
sha256,
|
|
444
|
-
body
|
|
461
|
+
body,
|
|
445
462
|
expiresAt,
|
|
446
463
|
});
|
|
447
464
|
}
|
|
448
465
|
}
|
|
449
466
|
|
|
450
467
|
snapshots.push({
|
|
451
|
-
table:
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
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);
|