@syncular/server 0.0.6-159 → 0.0.6-167

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.
Files changed (93) hide show
  1. package/dist/blobs/adapters/database.d.ts +26 -9
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +65 -21
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/manager.d.ts +60 -3
  6. package/dist/blobs/manager.d.ts.map +1 -1
  7. package/dist/blobs/manager.js +227 -56
  8. package/dist/blobs/manager.js.map +1 -1
  9. package/dist/blobs/migrate.d.ts.map +1 -1
  10. package/dist/blobs/migrate.js +16 -8
  11. package/dist/blobs/migrate.js.map +1 -1
  12. package/dist/blobs/types.d.ts +4 -0
  13. package/dist/blobs/types.d.ts.map +1 -1
  14. package/dist/dialect/helpers.d.ts +3 -0
  15. package/dist/dialect/helpers.d.ts.map +1 -1
  16. package/dist/dialect/helpers.js +17 -0
  17. package/dist/dialect/helpers.js.map +1 -1
  18. package/dist/handlers/collection.d.ts +0 -2
  19. package/dist/handlers/collection.d.ts.map +1 -1
  20. package/dist/handlers/collection.js +5 -56
  21. package/dist/handlers/collection.js.map +1 -1
  22. package/dist/handlers/create-handler.d.ts +0 -4
  23. package/dist/handlers/create-handler.d.ts.map +1 -1
  24. package/dist/handlers/create-handler.js +6 -34
  25. package/dist/handlers/create-handler.js.map +1 -1
  26. package/dist/notify.d.ts.map +1 -1
  27. package/dist/notify.js +13 -37
  28. package/dist/notify.js.map +1 -1
  29. package/dist/proxy/collection.d.ts +0 -2
  30. package/dist/proxy/collection.d.ts.map +1 -1
  31. package/dist/proxy/collection.js +2 -17
  32. package/dist/proxy/collection.js.map +1 -1
  33. package/dist/proxy/handler.d.ts +1 -1
  34. package/dist/proxy/handler.d.ts.map +1 -1
  35. package/dist/proxy/handler.js +1 -2
  36. package/dist/proxy/handler.js.map +1 -1
  37. package/dist/proxy/index.d.ts +1 -1
  38. package/dist/proxy/index.d.ts.map +1 -1
  39. package/dist/proxy/index.js +1 -1
  40. package/dist/proxy/index.js.map +1 -1
  41. package/dist/proxy/oplog.d.ts.map +1 -1
  42. package/dist/proxy/oplog.js +1 -7
  43. package/dist/proxy/oplog.js.map +1 -1
  44. package/dist/prune.d.ts.map +1 -1
  45. package/dist/prune.js +1 -13
  46. package/dist/prune.js.map +1 -1
  47. package/dist/pull.d.ts.map +1 -1
  48. package/dist/pull.js +186 -54
  49. package/dist/pull.js.map +1 -1
  50. package/dist/push.d.ts +1 -1
  51. package/dist/push.d.ts.map +1 -1
  52. package/dist/push.js +9 -36
  53. package/dist/push.js.map +1 -1
  54. package/dist/snapshot-chunks/db-metadata.d.ts +18 -0
  55. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  56. package/dist/snapshot-chunks/db-metadata.js +71 -23
  57. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  58. package/dist/snapshot-chunks.d.ts +5 -1
  59. package/dist/snapshot-chunks.d.ts.map +1 -1
  60. package/dist/snapshot-chunks.js +14 -1
  61. package/dist/snapshot-chunks.js.map +1 -1
  62. package/dist/stats.d.ts.map +1 -1
  63. package/dist/stats.js +1 -13
  64. package/dist/stats.js.map +1 -1
  65. package/dist/subscriptions/resolve.d.ts +1 -1
  66. package/dist/subscriptions/resolve.d.ts.map +1 -1
  67. package/dist/subscriptions/resolve.js +3 -16
  68. package/dist/subscriptions/resolve.js.map +1 -1
  69. package/dist/sync.d.ts.map +1 -1
  70. package/dist/sync.js +2 -4
  71. package/dist/sync.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/blobs/adapters/database.test.ts +7 -0
  74. package/src/blobs/adapters/database.ts +119 -39
  75. package/src/blobs/manager.ts +339 -53
  76. package/src/blobs/migrate.ts +16 -8
  77. package/src/blobs/types.ts +4 -0
  78. package/src/dialect/helpers.ts +19 -0
  79. package/src/handlers/collection.ts +17 -86
  80. package/src/handlers/create-handler.ts +9 -44
  81. package/src/notify.ts +15 -40
  82. package/src/proxy/collection.ts +5 -27
  83. package/src/proxy/handler.ts +2 -2
  84. package/src/proxy/index.ts +0 -2
  85. package/src/proxy/oplog.ts +1 -9
  86. package/src/prune.ts +1 -12
  87. package/src/pull.ts +280 -105
  88. package/src/push.ts +14 -43
  89. package/src/snapshot-chunks/db-metadata.ts +107 -27
  90. package/src/snapshot-chunks.ts +18 -0
  91. package/src/stats.ts +1 -12
  92. package/src/subscriptions/resolve.ts +4 -20
  93. package/src/sync.ts +6 -6
package/src/pull.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  encodeSnapshotRowFrames,
6
6
  encodeSnapshotRows,
7
7
  gzipBytes,
8
- gzipBytesToStream,
9
8
  randomId,
10
9
  type ScopeValues,
11
10
  SYNC_SNAPSHOT_CHUNK_COMPRESSION,
@@ -24,15 +23,15 @@ import type { Kysely } from 'kysely';
24
23
  import type { DbExecutor, ServerSyncDialect } from './dialect/types';
25
24
  import {
26
25
  getServerBootstrapOrderFor,
27
- getServerHandlerOrThrow,
28
26
  type ServerHandlerCollection,
29
27
  } from './handlers/collection';
30
- import type { ServerTableHandler, SyncServerAuth } from './handlers/types';
28
+ import type { SyncServerAuth } from './handlers/types';
31
29
  import { EXTERNAL_CLIENT_ID } from './notify';
32
30
  import type { SyncCoreDb } from './schema';
33
31
  import {
34
32
  insertSnapshotChunk,
35
33
  readSnapshotChunkRefByPageKey,
34
+ scopesToSnapshotChunkScopeKey,
36
35
  } from './snapshot-chunks';
37
36
  import type { SnapshotChunkStorage } from './snapshot-chunks/types';
38
37
  import {
@@ -62,6 +61,152 @@ function concatByteChunks(chunks: readonly Uint8Array[]): Uint8Array {
62
61
  return merged;
63
62
  }
64
63
 
64
+ function byteChunksToStream(
65
+ chunks: readonly Uint8Array[]
66
+ ): ReadableStream<BufferSource> {
67
+ return new ReadableStream<BufferSource>({
68
+ start(controller) {
69
+ for (const chunk of chunks) {
70
+ if (chunk.length === 0) continue;
71
+ controller.enqueue(chunk.slice());
72
+ }
73
+ controller.close();
74
+ },
75
+ });
76
+ }
77
+
78
+ function bufferSourceToUint8Array(chunk: BufferSource): Uint8Array {
79
+ if (chunk instanceof Uint8Array) {
80
+ return chunk;
81
+ }
82
+ if (chunk instanceof ArrayBuffer) {
83
+ return new Uint8Array(chunk);
84
+ }
85
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
86
+ }
87
+
88
+ async function streamToBytes(
89
+ stream: ReadableStream<BufferSource>
90
+ ): Promise<Uint8Array> {
91
+ const reader = stream.getReader();
92
+ const chunks: Uint8Array[] = [];
93
+ let total = 0;
94
+
95
+ try {
96
+ while (true) {
97
+ const { done, value } = await reader.read();
98
+ if (done) break;
99
+ if (!value) continue;
100
+ const bytes = bufferSourceToUint8Array(value);
101
+ if (bytes.length === 0) continue;
102
+ chunks.push(bytes);
103
+ total += bytes.length;
104
+ }
105
+ } finally {
106
+ reader.releaseLock();
107
+ }
108
+
109
+ if (chunks.length === 0) return new Uint8Array();
110
+ if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
111
+
112
+ const merged = new Uint8Array(total);
113
+ let offset = 0;
114
+ for (const chunk of chunks) {
115
+ merged.set(chunk, offset);
116
+ offset += chunk.length;
117
+ }
118
+ return merged;
119
+ }
120
+
121
+ function bufferSourceStreamToUint8ArrayStream(
122
+ stream: ReadableStream<BufferSource>
123
+ ): ReadableStream<Uint8Array> {
124
+ return new ReadableStream<Uint8Array>({
125
+ async start(controller) {
126
+ const reader = stream.getReader();
127
+ try {
128
+ while (true) {
129
+ const { done, value } = await reader.read();
130
+ if (done) break;
131
+ if (!value) continue;
132
+ const bytes = bufferSourceToUint8Array(value);
133
+ if (bytes.length === 0) continue;
134
+ controller.enqueue(bytes);
135
+ }
136
+ controller.close();
137
+ } catch (err) {
138
+ controller.error(err);
139
+ } finally {
140
+ reader.releaseLock();
141
+ }
142
+ },
143
+ });
144
+ }
145
+
146
+ let nodeCryptoModulePromise: Promise<
147
+ typeof import('node:crypto') | null
148
+ > | null = null;
149
+
150
+ async function getNodeCryptoModule(): Promise<
151
+ typeof import('node:crypto') | null
152
+ > {
153
+ if (!nodeCryptoModulePromise) {
154
+ nodeCryptoModulePromise = import('node:crypto').catch(() => null);
155
+ }
156
+ return nodeCryptoModulePromise;
157
+ }
158
+
159
+ async function sha256HexFromByteChunks(
160
+ chunks: readonly Uint8Array[]
161
+ ): Promise<string> {
162
+ const nodeCrypto = await getNodeCryptoModule();
163
+ if (nodeCrypto && typeof nodeCrypto.createHash === 'function') {
164
+ const hasher = nodeCrypto.createHash('sha256');
165
+ for (const chunk of chunks) {
166
+ if (chunk.length === 0) continue;
167
+ hasher.update(chunk);
168
+ }
169
+ return hasher.digest('hex');
170
+ }
171
+
172
+ return sha256Hex(concatByteChunks(chunks));
173
+ }
174
+
175
+ async function gzipByteChunks(
176
+ chunks: readonly Uint8Array[]
177
+ ): Promise<Uint8Array> {
178
+ if (typeof CompressionStream !== 'undefined') {
179
+ const stream = byteChunksToStream(chunks).pipeThrough(
180
+ new CompressionStream('gzip')
181
+ );
182
+ return streamToBytes(stream);
183
+ }
184
+
185
+ return gzipBytes(concatByteChunks(chunks));
186
+ }
187
+
188
+ async function gzipByteChunksToStream(chunks: readonly Uint8Array[]): Promise<{
189
+ stream: ReadableStream<Uint8Array>;
190
+ byteLength?: number;
191
+ }> {
192
+ if (typeof CompressionStream !== 'undefined') {
193
+ const source = byteChunksToStream(chunks).pipeThrough(
194
+ new CompressionStream('gzip')
195
+ );
196
+ return {
197
+ stream: bufferSourceStreamToUint8ArrayStream(source),
198
+ };
199
+ }
200
+
201
+ const compressed = await gzipBytes(concatByteChunks(chunks));
202
+ return {
203
+ stream: bufferSourceStreamToUint8ArrayStream(
204
+ byteChunksToStream([compressed])
205
+ ),
206
+ byteLength: compressed.length,
207
+ };
208
+ }
209
+
65
210
  export interface PullResult {
66
211
  response: SyncPullResponse;
67
212
  /**
@@ -83,22 +228,31 @@ interface PendingExternalChunkWrite {
83
228
  rowCursor: string | null;
84
229
  rowLimit: number;
85
230
  };
86
- rowFramePayload: Uint8Array;
231
+ rowFrameParts: Uint8Array[];
87
232
  expiresAt: string;
88
233
  }
89
234
 
90
- /**
91
- * Generate a stable cache key for snapshot chunks.
92
- */
93
- async function scopesToCacheKey(scopes: ScopeValues): Promise<string> {
94
- const sorted = Object.entries(scopes)
95
- .sort(([a], [b]) => a.localeCompare(b))
96
- .map(([k, v]) => {
97
- const arr = Array.isArray(v) ? [...v].sort() : [v];
98
- return `${k}:${arr.join(',')}`;
99
- })
100
- .join('|');
101
- return await sha256Hex(sorted);
235
+ async function runWithConcurrency<T>(
236
+ items: readonly T[],
237
+ concurrency: number,
238
+ worker: (item: T) => Promise<void>
239
+ ): Promise<void> {
240
+ if (items.length === 0) return;
241
+
242
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
243
+ let nextIndex = 0;
244
+
245
+ async function runWorker(): Promise<void> {
246
+ while (nextIndex < items.length) {
247
+ const index = nextIndex;
248
+ nextIndex += 1;
249
+ const item = items[index];
250
+ if (item === undefined) continue;
251
+ await worker(item);
252
+ }
253
+ }
254
+
255
+ await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
102
256
  }
103
257
 
104
258
  /**
@@ -239,31 +393,43 @@ function recordPullMetrics(args: {
239
393
  );
240
394
  }
241
395
 
242
- /**
243
- * Read synthetic commits created by notifyExternalDataChange() after a given cursor.
244
- * Returns commit_seq and affected tables for each external change commit.
245
- */
246
- async function readExternalDataChanges<DB extends SyncCoreDb>(
396
+ async function readLatestExternalCommitByTable<DB extends SyncCoreDb>(
247
397
  trx: DbExecutor<DB>,
248
- dialect: ServerSyncDialect,
249
- args: { partitionId: string; afterCursor: number }
250
- ): Promise<Array<{ commitSeq: number; tables: string[] }>> {
398
+ args: { partitionId: string; afterCursor: number; tables: string[] }
399
+ ): Promise<Map<string, number>> {
400
+ const tableNames = Array.from(
401
+ new Set(args.tables.filter((table) => typeof table === 'string'))
402
+ );
403
+ const latestByTable = new Map<string, number>();
404
+ if (tableNames.length === 0) {
405
+ return latestByTable;
406
+ }
407
+
251
408
  type SyncExecutor = Pick<Kysely<SyncCoreDb>, 'selectFrom'>;
252
409
  const executor = trx as SyncExecutor;
253
-
254
410
  const rows = await executor
255
- .selectFrom('sync_commits')
256
- .select(['commit_seq', 'affected_tables'])
257
- .where('partition_id', '=', args.partitionId)
258
- .where('client_id', '=', EXTERNAL_CLIENT_ID)
259
- .where('commit_seq', '>', args.afterCursor)
260
- .orderBy('commit_seq', 'asc')
411
+ .selectFrom('sync_table_commits as tc')
412
+ .innerJoin('sync_commits as cm', (join) =>
413
+ join
414
+ .onRef('cm.commit_seq', '=', 'tc.commit_seq')
415
+ .onRef('cm.partition_id', '=', 'tc.partition_id')
416
+ )
417
+ .select(['tc.table as table'])
418
+ .select((eb) => eb.fn.max('tc.commit_seq').as('latest_commit_seq'))
419
+ .where('tc.partition_id', '=', args.partitionId)
420
+ .where('cm.client_id', '=', EXTERNAL_CLIENT_ID)
421
+ .where('tc.commit_seq', '>', args.afterCursor)
422
+ .where('tc.table', 'in', tableNames)
423
+ .groupBy('tc.table')
261
424
  .execute();
262
425
 
263
- return rows.map((row) => ({
264
- commitSeq: Number(row.commit_seq),
265
- tables: dialect.dbToArray(row.affected_tables),
266
- }));
426
+ for (const row of rows) {
427
+ const commitSeq = Number(row.latest_commit_seq ?? -1);
428
+ if (!Number.isFinite(commitSeq) || commitSeq < 0) continue;
429
+ latestByTable.set(row.table, commitSeq);
430
+ }
431
+
432
+ return latestByTable;
267
433
  }
268
434
 
269
435
  export async function pull<
@@ -350,39 +516,35 @@ export async function pull<
350
516
  // Detect external data changes (synthetic commits from notifyExternalDataChange)
351
517
  // Compute minimum cursor across all active subscriptions to scope the query.
352
518
  let minSubCursor = Number.MAX_SAFE_INTEGER;
519
+ const activeTables = new Set<string>();
353
520
  for (const sub of resolved) {
354
521
  if (
355
522
  sub.status === 'revoked' ||
356
523
  Object.keys(sub.scopes).length === 0
357
524
  )
358
525
  continue;
526
+ activeTables.add(sub.table);
359
527
  const cursor = Math.max(-1, sub.cursor ?? -1);
360
528
  if (cursor >= 0 && cursor < minSubCursor) {
361
529
  minSubCursor = cursor;
362
530
  }
363
531
  }
364
532
 
365
- const externalDataChanges =
533
+ const maxExternalCommitByTable =
366
534
  minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
367
- ? await readExternalDataChanges(trx, dialect, {
535
+ ? await readLatestExternalCommitByTable(trx, {
368
536
  partitionId,
369
537
  afterCursor: minSubCursor,
538
+ tables: Array.from(activeTables),
370
539
  })
371
- : [];
372
- const maxExternalCommitByTable = new Map<string, number>();
373
- for (const change of externalDataChanges) {
374
- for (const table of change.tables) {
375
- const previous = maxExternalCommitByTable.get(table) ?? -1;
376
- if (change.commitSeq > previous) {
377
- maxExternalCommitByTable.set(table, change.commitSeq);
378
- }
379
- }
380
- }
540
+ : new Map<string, number>();
381
541
 
382
542
  for (const sub of resolved) {
383
543
  const cursor = Math.max(-1, sub.cursor ?? -1);
384
544
  // Validate table handler exists (throws if not registered)
385
- getServerHandlerOrThrow(args.handlers, sub.table);
545
+ if (!args.handlers.byTable.has(sub.table)) {
546
+ throw new Error(`Unknown table: ${sub.table}`);
547
+ }
386
548
 
387
549
  if (
388
550
  sub.status === 'revoked' ||
@@ -460,7 +622,9 @@ export async function pull<
460
622
 
461
623
  const snapshots: SyncSnapshot[] = [];
462
624
  let nextState: SyncBootstrapState | null = effectiveState;
463
- const cacheKey = `${partitionId}:${await scopesToCacheKey(effectiveScopes)}`;
625
+ const cacheKey = `${partitionId}:${await scopesToSnapshotChunkScopeKey(
626
+ effectiveScopes
627
+ )}`;
464
628
 
465
629
  interface SnapshotBundle {
466
630
  table: string;
@@ -495,9 +659,6 @@ export async function pull<
495
659
 
496
660
  let chunkRef = cached;
497
661
  if (!chunkRef) {
498
- const rowFramePayload = concatByteChunks(
499
- bundle.rowFrameParts
500
- );
501
662
  const expiresAt = new Date(
502
663
  Date.now() + Math.max(1000, bundle.ttlMs)
503
664
  ).toISOString();
@@ -521,13 +682,17 @@ export async function pull<
521
682
  rowCursor: bundle.startCursor,
522
683
  rowLimit: bundleRowLimit,
523
684
  },
524
- rowFramePayload,
685
+ rowFrameParts: [...bundle.rowFrameParts],
525
686
  expiresAt,
526
687
  });
527
688
  return;
528
689
  }
529
- const sha256 = await sha256Hex(rowFramePayload);
530
- const compressedBody = await gzipBytes(rowFramePayload);
690
+ const sha256 = await sha256HexFromByteChunks(
691
+ bundle.rowFrameParts
692
+ );
693
+ const compressedBody = await gzipByteChunks(
694
+ bundle.rowFrameParts
695
+ );
531
696
  const chunkId = randomId();
532
697
  chunkRef = await insertSnapshotChunk(trx, {
533
698
  chunkId,
@@ -575,8 +740,10 @@ export async function pull<
575
740
  break;
576
741
  }
577
742
 
578
- const tableHandler: ServerTableHandler<DB, Auth> =
579
- getServerHandlerOrThrow(args.handlers, nextTableName);
743
+ const tableHandler = args.handlers.byTable.get(nextTableName);
744
+ if (!tableHandler) {
745
+ throw new Error(`Unknown table: ${nextTableName}`);
746
+ }
580
747
  if (!activeBundle || activeBundle.table !== nextTableName) {
581
748
  if (activeBundle) {
582
749
  await flushSnapshotBundle(activeBundle);
@@ -846,57 +1013,65 @@ export async function pull<
846
1013
 
847
1014
  const chunkStorage = args.chunkStorage;
848
1015
  if (chunkStorage && pendingExternalChunkWrites.length > 0) {
849
- for (const pending of pendingExternalChunkWrites) {
850
- let chunkRef = await readSnapshotChunkRefByPageKey(db, {
851
- partitionId: pending.cacheLookup.partitionId,
852
- scopeKey: pending.cacheLookup.scopeKey,
853
- scope: pending.cacheLookup.scope,
854
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
855
- rowCursor: pending.cacheLookup.rowCursor,
856
- rowLimit: pending.cacheLookup.rowLimit,
857
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
858
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
859
- });
1016
+ await runWithConcurrency(
1017
+ pendingExternalChunkWrites,
1018
+ 4,
1019
+ async (pending) => {
1020
+ let chunkRef = await readSnapshotChunkRefByPageKey(db, {
1021
+ partitionId: pending.cacheLookup.partitionId,
1022
+ scopeKey: pending.cacheLookup.scopeKey,
1023
+ scope: pending.cacheLookup.scope,
1024
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1025
+ rowCursor: pending.cacheLookup.rowCursor,
1026
+ rowLimit: pending.cacheLookup.rowLimit,
1027
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1028
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1029
+ });
860
1030
 
861
- if (!chunkRef) {
862
- const sha256 = await sha256Hex(pending.rowFramePayload);
863
- if (chunkStorage.storeChunkStream) {
864
- const { stream: bodyStream, byteLength } =
865
- await gzipBytesToStream(pending.rowFramePayload);
866
- chunkRef = await chunkStorage.storeChunkStream({
867
- partitionId: pending.cacheLookup.partitionId,
868
- scopeKey: pending.cacheLookup.scopeKey,
869
- scope: pending.cacheLookup.scope,
870
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
871
- rowCursor: pending.cacheLookup.rowCursor,
872
- rowLimit: pending.cacheLookup.rowLimit,
873
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
874
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
875
- sha256,
876
- byteLength,
877
- bodyStream,
878
- expiresAt: pending.expiresAt,
879
- });
880
- } else {
881
- const compressedBody = await gzipBytes(pending.rowFramePayload);
882
- chunkRef = await chunkStorage.storeChunk({
883
- partitionId: pending.cacheLookup.partitionId,
884
- scopeKey: pending.cacheLookup.scopeKey,
885
- scope: pending.cacheLookup.scope,
886
- asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
887
- rowCursor: pending.cacheLookup.rowCursor,
888
- rowLimit: pending.cacheLookup.rowLimit,
889
- encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
890
- compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
891
- sha256,
892
- body: compressedBody,
893
- expiresAt: pending.expiresAt,
894
- });
1031
+ if (!chunkRef) {
1032
+ const sha256 = await sha256HexFromByteChunks(
1033
+ pending.rowFrameParts
1034
+ );
1035
+ if (chunkStorage.storeChunkStream) {
1036
+ const { stream: bodyStream, byteLength } =
1037
+ await gzipByteChunksToStream(pending.rowFrameParts);
1038
+ chunkRef = await chunkStorage.storeChunkStream({
1039
+ partitionId: pending.cacheLookup.partitionId,
1040
+ scopeKey: pending.cacheLookup.scopeKey,
1041
+ scope: pending.cacheLookup.scope,
1042
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1043
+ rowCursor: pending.cacheLookup.rowCursor,
1044
+ rowLimit: pending.cacheLookup.rowLimit,
1045
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1046
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1047
+ sha256,
1048
+ byteLength,
1049
+ bodyStream,
1050
+ expiresAt: pending.expiresAt,
1051
+ });
1052
+ } else {
1053
+ const compressedBody = await gzipByteChunks(
1054
+ pending.rowFrameParts
1055
+ );
1056
+ chunkRef = await chunkStorage.storeChunk({
1057
+ partitionId: pending.cacheLookup.partitionId,
1058
+ scopeKey: pending.cacheLookup.scopeKey,
1059
+ scope: pending.cacheLookup.scope,
1060
+ asOfCommitSeq: pending.cacheLookup.asOfCommitSeq,
1061
+ rowCursor: pending.cacheLookup.rowCursor,
1062
+ rowLimit: pending.cacheLookup.rowLimit,
1063
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
1064
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
1065
+ sha256,
1066
+ body: compressedBody,
1067
+ expiresAt: pending.expiresAt,
1068
+ });
1069
+ }
895
1070
  }
896
- }
897
1071
 
898
- pending.snapshot.chunks = [chunkRef];
899
- }
1072
+ pending.snapshot.chunks = [chunkRef];
1073
+ }
1074
+ );
900
1075
  }
901
1076
 
902
1077
  const durationMs = Math.max(0, Date.now() - startedAtMs);
package/src/push.ts CHANGED
@@ -15,11 +15,13 @@ import type {
15
15
  Updateable,
16
16
  } from 'kysely';
17
17
  import { sql } from 'kysely';
18
- import type { ServerSyncDialect } from './dialect/types';
19
18
  import {
20
- getServerHandlerOrThrow,
21
- type ServerHandlerCollection,
22
- } from './handlers/collection';
19
+ coerceNumber,
20
+ parseJsonValue,
21
+ toDialectJsonValue,
22
+ } from './dialect/helpers';
23
+ import type { ServerSyncDialect } from './dialect/types';
24
+ import type { ServerHandlerCollection } from './handlers/collection';
23
25
  import type { SyncServerAuth } from './handlers/types';
24
26
  import {
25
27
  type SyncServerPushPlugin,
@@ -67,40 +69,6 @@ class RejectCommitError extends Error {
67
69
  }
68
70
  }
69
71
 
70
- function toDialectJsonValue(
71
- dialect: ServerSyncDialect,
72
- value: unknown
73
- ): unknown {
74
- if (value === null || value === undefined) return null;
75
- if (dialect.family === 'sqlite') return JSON.stringify(value);
76
- return value;
77
- }
78
-
79
- function parseJsonValue(value: unknown): unknown {
80
- if (typeof value !== 'string') return value;
81
- try {
82
- return JSON.parse(value);
83
- } catch {
84
- return value;
85
- }
86
- }
87
-
88
- function coerceNumber(value: unknown): number | null {
89
- if (value === null || value === undefined) return null;
90
- if (typeof value === 'number') {
91
- return Number.isFinite(value) ? value : null;
92
- }
93
- if (typeof value === 'bigint') {
94
- const coerced = Number(value);
95
- return Number.isFinite(coerced) ? coerced : null;
96
- }
97
- if (typeof value === 'string') {
98
- const coerced = Number(value);
99
- return Number.isFinite(coerced) ? coerced : null;
100
- }
101
- return null;
102
- }
103
-
104
72
  function isRecord(value: unknown): value is Record<string, unknown> {
105
73
  return typeof value === 'object' && value !== null;
106
74
  }
@@ -537,10 +505,10 @@ export async function pushCommit<
537
505
  const savepointName = 'sync_apply';
538
506
  let useSavepoints = dialect.supportsSavepoints;
539
507
  if (useSavepoints && ops.length === 1) {
540
- const singleOpHandler = getServerHandlerOrThrow(
541
- handlers,
542
- ops[0]!.table
543
- );
508
+ const singleOpHandler = handlers.byTable.get(ops[0]!.table);
509
+ if (!singleOpHandler) {
510
+ throw new Error(`Unknown table: ${ops[0]!.table}`);
511
+ }
544
512
  if (singleOpHandler.canRejectSingleOperationWithoutSavepoint) {
545
513
  useSavepoints = false;
546
514
  }
@@ -561,7 +529,10 @@ export async function pushCommit<
561
529
 
562
530
  for (let i = 0; i < ops.length; ) {
563
531
  const op = ops[i]!;
564
- const handler = getServerHandlerOrThrow(handlers, op.table);
532
+ const handler = handlers.byTable.get(op.table);
533
+ if (!handler) {
534
+ throw new Error(`Unknown table: ${op.table}`);
535
+ }
565
536
 
566
537
  const operationCtx = {
567
538
  db: trx,