@syncular/server 0.0.1 → 0.0.2-126

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 (171) hide show
  1. package/README.md +25 -0
  2. package/dist/blobs/adapters/database.d.ts.map +1 -1
  3. package/dist/blobs/adapters/database.js +25 -3
  4. package/dist/blobs/adapters/database.js.map +1 -1
  5. package/dist/blobs/adapters/filesystem.d.ts +31 -0
  6. package/dist/blobs/adapters/filesystem.d.ts.map +1 -0
  7. package/dist/blobs/adapters/filesystem.js +140 -0
  8. package/dist/blobs/adapters/filesystem.js.map +1 -0
  9. package/dist/blobs/adapters/s3.d.ts +3 -2
  10. package/dist/blobs/adapters/s3.d.ts.map +1 -1
  11. package/dist/blobs/adapters/s3.js +49 -0
  12. package/dist/blobs/adapters/s3.js.map +1 -1
  13. package/dist/blobs/index.d.ts +1 -0
  14. package/dist/blobs/index.d.ts.map +1 -1
  15. package/dist/blobs/index.js +6 -5
  16. package/dist/blobs/index.js.map +1 -1
  17. package/dist/clients.d.ts +1 -0
  18. package/dist/clients.d.ts.map +1 -1
  19. package/dist/clients.js.map +1 -1
  20. package/dist/compaction.d.ts +1 -1
  21. package/dist/compaction.js +1 -1
  22. package/dist/dialect/base.d.ts +83 -0
  23. package/dist/dialect/base.d.ts.map +1 -0
  24. package/dist/dialect/base.js +144 -0
  25. package/dist/dialect/base.js.map +1 -0
  26. package/dist/dialect/helpers.d.ts +10 -0
  27. package/dist/dialect/helpers.d.ts.map +1 -0
  28. package/dist/dialect/helpers.js +59 -0
  29. package/dist/dialect/helpers.js.map +1 -0
  30. package/dist/dialect/index.d.ts +2 -0
  31. package/dist/dialect/index.d.ts.map +1 -1
  32. package/dist/dialect/index.js +3 -1
  33. package/dist/dialect/index.js.map +1 -1
  34. package/dist/dialect/types.d.ts +38 -46
  35. package/dist/dialect/types.d.ts.map +1 -1
  36. package/dist/{shapes → handlers}/create-handler.d.ts +18 -5
  37. package/dist/handlers/create-handler.d.ts.map +1 -0
  38. package/dist/{shapes → handlers}/create-handler.js +140 -43
  39. package/dist/handlers/create-handler.js.map +1 -0
  40. package/dist/handlers/index.d.ts.map +1 -0
  41. package/dist/handlers/index.js +4 -0
  42. package/dist/handlers/index.js.map +1 -0
  43. package/dist/handlers/registry.d.ts.map +1 -0
  44. package/dist/handlers/registry.js.map +1 -0
  45. package/dist/{shapes → handlers}/types.d.ts +7 -7
  46. package/dist/{shapes → handlers}/types.d.ts.map +1 -1
  47. package/dist/{shapes → handlers}/types.js.map +1 -1
  48. package/dist/helpers/conflict.d.ts +1 -1
  49. package/dist/helpers/conflict.d.ts.map +1 -1
  50. package/dist/helpers/emitted-change.d.ts +1 -1
  51. package/dist/helpers/emitted-change.d.ts.map +1 -1
  52. package/dist/helpers/index.js +4 -4
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +17 -16
  56. package/dist/index.js.map +1 -1
  57. package/dist/notify.d.ts +47 -0
  58. package/dist/notify.d.ts.map +1 -0
  59. package/dist/notify.js +85 -0
  60. package/dist/notify.js.map +1 -0
  61. package/dist/proxy/handler.d.ts +1 -1
  62. package/dist/proxy/handler.d.ts.map +1 -1
  63. package/dist/proxy/handler.js +15 -11
  64. package/dist/proxy/handler.js.map +1 -1
  65. package/dist/proxy/index.d.ts +2 -2
  66. package/dist/proxy/index.d.ts.map +1 -1
  67. package/dist/proxy/index.js +3 -3
  68. package/dist/proxy/index.js.map +1 -1
  69. package/dist/proxy/mutation-detector.d.ts +4 -0
  70. package/dist/proxy/mutation-detector.d.ts.map +1 -1
  71. package/dist/proxy/mutation-detector.js +209 -24
  72. package/dist/proxy/mutation-detector.js.map +1 -1
  73. package/dist/proxy/oplog.d.ts +2 -1
  74. package/dist/proxy/oplog.d.ts.map +1 -1
  75. package/dist/proxy/oplog.js +15 -9
  76. package/dist/proxy/oplog.js.map +1 -1
  77. package/dist/proxy/registry.d.ts +0 -11
  78. package/dist/proxy/registry.d.ts.map +1 -1
  79. package/dist/proxy/registry.js +0 -24
  80. package/dist/proxy/registry.js.map +1 -1
  81. package/dist/proxy/types.d.ts +2 -0
  82. package/dist/proxy/types.d.ts.map +1 -1
  83. package/dist/pull.d.ts +4 -3
  84. package/dist/pull.d.ts.map +1 -1
  85. package/dist/pull.js +565 -314
  86. package/dist/pull.js.map +1 -1
  87. package/dist/push.d.ts +15 -3
  88. package/dist/push.d.ts.map +1 -1
  89. package/dist/push.js +359 -229
  90. package/dist/push.js.map +1 -1
  91. package/dist/realtime/index.js +1 -1
  92. package/dist/realtime/types.d.ts +2 -0
  93. package/dist/realtime/types.d.ts.map +1 -1
  94. package/dist/schema.d.ts +11 -1
  95. package/dist/schema.d.ts.map +1 -1
  96. package/dist/snapshot-chunks/db-metadata.d.ts +6 -1
  97. package/dist/snapshot-chunks/db-metadata.d.ts.map +1 -1
  98. package/dist/snapshot-chunks/db-metadata.js +261 -92
  99. package/dist/snapshot-chunks/db-metadata.js.map +1 -1
  100. package/dist/snapshot-chunks/index.d.ts +0 -1
  101. package/dist/snapshot-chunks/index.d.ts.map +1 -1
  102. package/dist/snapshot-chunks/index.js +2 -3
  103. package/dist/snapshot-chunks/index.js.map +1 -1
  104. package/dist/snapshot-chunks/types.d.ts +20 -5
  105. package/dist/snapshot-chunks/types.d.ts.map +1 -1
  106. package/dist/snapshot-chunks.d.ts +12 -8
  107. package/dist/snapshot-chunks.d.ts.map +1 -1
  108. package/dist/snapshot-chunks.js +40 -12
  109. package/dist/snapshot-chunks.js.map +1 -1
  110. package/dist/subscriptions/index.js +1 -1
  111. package/dist/subscriptions/resolve.d.ts +6 -6
  112. package/dist/subscriptions/resolve.d.ts.map +1 -1
  113. package/dist/subscriptions/resolve.js +53 -14
  114. package/dist/subscriptions/resolve.js.map +1 -1
  115. package/package.json +28 -7
  116. package/src/blobs/adapters/database.test.ts +67 -0
  117. package/src/blobs/adapters/database.ts +34 -9
  118. package/src/blobs/adapters/filesystem.test.ts +132 -0
  119. package/src/blobs/adapters/filesystem.ts +189 -0
  120. package/src/blobs/adapters/s3.test.ts +522 -0
  121. package/src/blobs/adapters/s3.ts +55 -2
  122. package/src/blobs/index.ts +1 -0
  123. package/src/clients.ts +1 -0
  124. package/src/compaction.ts +1 -1
  125. package/src/dialect/base.ts +292 -0
  126. package/src/dialect/helpers.ts +61 -0
  127. package/src/dialect/index.ts +2 -0
  128. package/src/dialect/types.ts +50 -54
  129. package/src/{shapes → handlers}/create-handler.ts +219 -64
  130. package/src/{shapes → handlers}/types.ts +10 -7
  131. package/src/helpers/conflict.ts +1 -1
  132. package/src/helpers/emitted-change.ts +1 -1
  133. package/src/index.ts +2 -1
  134. package/src/notify.test.ts +516 -0
  135. package/src/notify.ts +131 -0
  136. package/src/proxy/handler.test.ts +120 -0
  137. package/src/proxy/handler.ts +18 -10
  138. package/src/proxy/index.ts +2 -1
  139. package/src/proxy/mutation-detector.test.ts +71 -0
  140. package/src/proxy/mutation-detector.ts +227 -29
  141. package/src/proxy/oplog.ts +19 -10
  142. package/src/proxy/registry.ts +0 -33
  143. package/src/proxy/types.ts +2 -0
  144. package/src/pull.ts +788 -405
  145. package/src/push.ts +507 -312
  146. package/src/realtime/types.ts +2 -0
  147. package/src/schema.ts +11 -1
  148. package/src/snapshot-chunks/db-metadata.test.ts +169 -0
  149. package/src/snapshot-chunks/db-metadata.ts +347 -105
  150. package/src/snapshot-chunks/index.ts +0 -1
  151. package/src/snapshot-chunks/types.ts +31 -5
  152. package/src/snapshot-chunks.ts +60 -21
  153. package/src/subscriptions/resolve.ts +73 -18
  154. package/dist/shapes/create-handler.d.ts.map +0 -1
  155. package/dist/shapes/create-handler.js.map +0 -1
  156. package/dist/shapes/index.d.ts.map +0 -1
  157. package/dist/shapes/index.js +0 -4
  158. package/dist/shapes/index.js.map +0 -1
  159. package/dist/shapes/registry.d.ts.map +0 -1
  160. package/dist/shapes/registry.js.map +0 -1
  161. package/dist/snapshot-chunks/adapters/s3.d.ts +0 -63
  162. package/dist/snapshot-chunks/adapters/s3.d.ts.map +0 -1
  163. package/dist/snapshot-chunks/adapters/s3.js +0 -50
  164. package/dist/snapshot-chunks/adapters/s3.js.map +0 -1
  165. package/src/snapshot-chunks/adapters/s3.ts +0 -68
  166. /package/dist/{shapes → handlers}/index.d.ts +0 -0
  167. /package/dist/{shapes → handlers}/registry.d.ts +0 -0
  168. /package/dist/{shapes → handlers}/registry.js +0 -0
  169. /package/dist/{shapes → handlers}/types.js +0 -0
  170. /package/src/{shapes → handlers}/index.ts +0 -0
  171. /package/src/{shapes → handlers}/registry.ts +0 -0
package/src/pull.ts CHANGED
@@ -1,20 +1,29 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { promisify } from 'node:util';
3
3
  import { gzip, gzipSync } from 'node:zlib';
4
- import type {
5
- ScopeValues,
6
- SyncBootstrapState,
7
- SyncChange,
8
- SyncCommit,
9
- SyncPullRequest,
10
- SyncPullResponse,
11
- SyncPullSubscriptionResponse,
12
- SyncSnapshot,
4
+ import {
5
+ captureSyncException,
6
+ countSyncMetric,
7
+ distributionSyncMetric,
8
+ encodeSnapshotRowFrames,
9
+ encodeSnapshotRows,
10
+ type ScopeValues,
11
+ SYNC_SNAPSHOT_CHUNK_COMPRESSION,
12
+ SYNC_SNAPSHOT_CHUNK_ENCODING,
13
+ type SyncBootstrapState,
14
+ type SyncChange,
15
+ type SyncCommit,
16
+ type SyncPullRequest,
17
+ type SyncPullResponse,
18
+ type SyncPullSubscriptionResponse,
19
+ type SyncSnapshot,
20
+ startSyncSpan,
13
21
  } from '@syncular/core';
14
22
  import type { Kysely } from 'kysely';
15
- import type { ServerSyncDialect } from './dialect/types';
23
+ import type { DbExecutor, ServerSyncDialect } from './dialect/types';
24
+ import type { TableRegistry } from './handlers/registry';
25
+ import { EXTERNAL_CLIENT_ID } from './notify';
16
26
  import type { SyncCoreDb } from './schema';
17
- import type { TableRegistry } from './shapes/registry';
18
27
  import {
19
28
  insertSnapshotChunk,
20
29
  readSnapshotChunkRefByPageKey,
@@ -25,14 +34,81 @@ import { resolveEffectiveScopesForSubscriptions } from './subscriptions/resolve'
25
34
  const gzipAsync = promisify(gzip);
26
35
  const ASYNC_GZIP_MIN_BYTES = 64 * 1024;
27
36
 
28
- async function compressSnapshotNdjson(ndjson: string): Promise<Uint8Array> {
29
- if (Buffer.byteLength(ndjson) < ASYNC_GZIP_MIN_BYTES) {
30
- return new Uint8Array(gzipSync(ndjson));
37
+ function concatByteChunks(chunks: readonly Uint8Array[]): Uint8Array {
38
+ if (chunks.length === 1) {
39
+ return chunks[0] ?? new Uint8Array();
40
+ }
41
+
42
+ let total = 0;
43
+ for (const chunk of chunks) {
44
+ total += chunk.length;
31
45
  }
32
- const compressed = await gzipAsync(ndjson);
46
+
47
+ const merged = new Uint8Array(total);
48
+ let offset = 0;
49
+ for (const chunk of chunks) {
50
+ merged.set(chunk, offset);
51
+ offset += chunk.length;
52
+ }
53
+ return merged;
54
+ }
55
+
56
+ function bytesToReadableStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
57
+ return new ReadableStream<Uint8Array>({
58
+ start(controller) {
59
+ controller.enqueue(bytes);
60
+ controller.close();
61
+ },
62
+ });
63
+ }
64
+
65
+ function chunksToReadableStream(
66
+ chunks: readonly Uint8Array[]
67
+ ): ReadableStream<Uint8Array> {
68
+ return new ReadableStream<Uint8Array>({
69
+ start(controller) {
70
+ for (const chunk of chunks) {
71
+ controller.enqueue(chunk);
72
+ }
73
+ controller.close();
74
+ },
75
+ });
76
+ }
77
+
78
+ async function compressSnapshotPayload(
79
+ payload: Uint8Array
80
+ ): Promise<Uint8Array> {
81
+ if (payload.byteLength < ASYNC_GZIP_MIN_BYTES) {
82
+ return new Uint8Array(gzipSync(payload));
83
+ }
84
+ const compressed = await gzipAsync(payload);
33
85
  return new Uint8Array(compressed);
34
86
  }
35
87
 
88
+ async function compressSnapshotPayloadStream(
89
+ chunks: readonly Uint8Array[]
90
+ ): Promise<{
91
+ stream: ReadableStream<Uint8Array>;
92
+ byteLength?: number;
93
+ }> {
94
+ if (typeof CompressionStream !== 'undefined') {
95
+ const source = chunksToReadableStream(chunks);
96
+ const gzipStream = new CompressionStream(
97
+ 'gzip'
98
+ ) as unknown as TransformStream<Uint8Array, Uint8Array>;
99
+ return {
100
+ stream: source.pipeThrough(gzipStream),
101
+ };
102
+ }
103
+
104
+ const payload = concatByteChunks(chunks);
105
+ const compressed = await compressSnapshotPayload(payload);
106
+ return {
107
+ stream: bytesToReadableStream(compressed),
108
+ byteLength: compressed.length,
109
+ };
110
+ }
111
+
36
112
  export interface PullResult {
37
113
  response: SyncPullResponse;
38
114
  /**
@@ -96,11 +172,139 @@ function mergeScopes(subscriptions: { scopes: ScopeValues }[]): ScopeValues {
96
172
  return merged;
97
173
  }
98
174
 
175
+ interface PullResponseStats {
176
+ subscriptionCount: number;
177
+ activeSubscriptionCount: number;
178
+ revokedSubscriptionCount: number;
179
+ bootstrapSubscriptionCount: number;
180
+ commitCount: number;
181
+ changeCount: number;
182
+ snapshotPageCount: number;
183
+ }
184
+
185
+ function summarizePullResponse(response: SyncPullResponse): PullResponseStats {
186
+ const subscriptions = response.subscriptions ?? [];
187
+ let activeSubscriptionCount = 0;
188
+ let revokedSubscriptionCount = 0;
189
+ let bootstrapSubscriptionCount = 0;
190
+ let commitCount = 0;
191
+ let changeCount = 0;
192
+ let snapshotPageCount = 0;
193
+
194
+ for (const sub of subscriptions) {
195
+ if (sub.status === 'revoked') {
196
+ revokedSubscriptionCount += 1;
197
+ } else {
198
+ activeSubscriptionCount += 1;
199
+ }
200
+
201
+ if (sub.bootstrap) {
202
+ bootstrapSubscriptionCount += 1;
203
+ }
204
+
205
+ const commits = sub.commits ?? [];
206
+ commitCount += commits.length;
207
+ for (const commit of commits) {
208
+ changeCount += commit.changes?.length ?? 0;
209
+ }
210
+
211
+ snapshotPageCount += sub.snapshots?.length ?? 0;
212
+ }
213
+
214
+ return {
215
+ subscriptionCount: subscriptions.length,
216
+ activeSubscriptionCount,
217
+ revokedSubscriptionCount,
218
+ bootstrapSubscriptionCount,
219
+ commitCount,
220
+ changeCount,
221
+ snapshotPageCount,
222
+ };
223
+ }
224
+
225
+ function recordPullMetrics(args: {
226
+ status: string;
227
+ dedupeRows: boolean;
228
+ durationMs: number;
229
+ stats: PullResponseStats;
230
+ }): void {
231
+ const { status, dedupeRows, durationMs, stats } = args;
232
+ const attributes = {
233
+ status,
234
+ dedupe_rows: dedupeRows,
235
+ };
236
+
237
+ countSyncMetric('sync.server.pull.requests', 1, { attributes });
238
+ distributionSyncMetric('sync.server.pull.duration_ms', durationMs, {
239
+ unit: 'millisecond',
240
+ attributes,
241
+ });
242
+ distributionSyncMetric(
243
+ 'sync.server.pull.subscriptions',
244
+ stats.subscriptionCount,
245
+ { attributes }
246
+ );
247
+ distributionSyncMetric(
248
+ 'sync.server.pull.active_subscriptions',
249
+ stats.activeSubscriptionCount,
250
+ { attributes }
251
+ );
252
+ distributionSyncMetric(
253
+ 'sync.server.pull.revoked_subscriptions',
254
+ stats.revokedSubscriptionCount,
255
+ { attributes }
256
+ );
257
+ distributionSyncMetric(
258
+ 'sync.server.pull.bootstrap_subscriptions',
259
+ stats.bootstrapSubscriptionCount,
260
+ { attributes }
261
+ );
262
+ distributionSyncMetric('sync.server.pull.commits', stats.commitCount, {
263
+ attributes,
264
+ });
265
+ distributionSyncMetric('sync.server.pull.changes', stats.changeCount, {
266
+ attributes,
267
+ });
268
+ distributionSyncMetric(
269
+ 'sync.server.pull.snapshot_pages',
270
+ stats.snapshotPageCount,
271
+ { attributes }
272
+ );
273
+ }
274
+
275
+ /**
276
+ * Read synthetic commits created by notifyExternalDataChange() after a given cursor.
277
+ * Returns commit_seq and affected tables for each external change commit.
278
+ */
279
+ async function readExternalDataChanges<DB extends SyncCoreDb>(
280
+ trx: DbExecutor<DB>,
281
+ dialect: ServerSyncDialect,
282
+ args: { partitionId: string; afterCursor: number }
283
+ ): Promise<Array<{ commitSeq: number; tables: string[] }>> {
284
+ type SyncExecutor = Pick<Kysely<SyncCoreDb>, 'selectFrom'>;
285
+ const executor = trx as SyncExecutor;
286
+
287
+ const rows = await executor
288
+ .selectFrom('sync_commits')
289
+ .select(['commit_seq', 'affected_tables'])
290
+ .where('partition_id', '=', args.partitionId)
291
+ .where('client_id', '=', EXTERNAL_CLIENT_ID)
292
+ .where('commit_seq', '>', args.afterCursor)
293
+ .orderBy('commit_seq', 'asc')
294
+ .execute();
295
+
296
+ return rows.map((row) => ({
297
+ commitSeq: Number(row.commit_seq),
298
+ tables: dialect.dbToArray(row.affected_tables),
299
+ }));
300
+ }
301
+
99
302
  export async function pull<DB extends SyncCoreDb>(args: {
100
303
  db: Kysely<DB>;
101
304
  dialect: ServerSyncDialect;
102
- shapes: TableRegistry<DB>;
305
+ handlers: TableRegistry<DB>;
103
306
  actorId: string;
307
+ partitionId?: string;
104
308
  request: SyncPullRequest;
105
309
  /**
106
310
  * Optional snapshot chunk storage adapter.
@@ -111,419 +315,598 @@ export async function pull<DB extends SyncCoreDb>(args: {
111
315
  }): Promise<PullResult> {
112
316
  const { request, dialect } = args;
113
317
  const db = args.db;
114
-
115
- // Validate and sanitize request limits
116
- const limitCommits = sanitizeLimit(request.limitCommits, 50, 1, 500);
117
- const limitSnapshotRows = sanitizeLimit(
118
- request.limitSnapshotRows,
119
- 1000,
120
- 1,
121
- 5000
122
- );
123
- const maxSnapshotPages = sanitizeLimit(request.maxSnapshotPages, 1, 1, 50);
124
- const dedupeRows = request.dedupeRows === true;
125
-
126
- // Resolve effective scopes for each subscription
127
- const resolved = await resolveEffectiveScopesForSubscriptions({
128
- db,
129
- actorId: args.actorId,
130
- subscriptions: request.subscriptions ?? [],
131
- shapes: args.shapes,
132
- });
133
-
134
- return dialect.executeInTransaction(db, async (trx) => {
135
- await dialect.setRepeatableRead(trx);
136
-
137
- const maxCommitSeq = await dialect.readMaxCommitSeq(trx);
138
- const minCommitSeq = await dialect.readMinCommitSeq(trx);
139
-
140
- const subResponses: SyncPullSubscriptionResponse[] = [];
141
- const activeSubscriptions: { scopes: ScopeValues }[] = [];
142
- const nextCursors: number[] = [];
143
-
144
- for (const sub of resolved) {
145
- const cursor = Math.max(-1, sub.cursor ?? -1);
146
- // Validate shape exists (throws if not registered)
147
- args.shapes.getOrThrow(sub.shape);
148
-
149
- if (sub.status === 'revoked' || Object.keys(sub.scopes).length === 0) {
150
- subResponses.push({
151
- id: sub.id,
152
- status: 'revoked',
153
- scopes: {},
154
- bootstrap: false,
155
- nextCursor: cursor,
156
- commits: [],
318
+ const partitionId = args.partitionId ?? 'default';
319
+ const requestedSubscriptionCount = Array.isArray(request.subscriptions)
320
+ ? request.subscriptions.length
321
+ : 0;
322
+ const startedAtMs = Date.now();
323
+
324
+ return startSyncSpan(
325
+ {
326
+ name: 'sync.server.pull',
327
+ op: 'sync.pull',
328
+ attributes: {
329
+ requested_subscription_count: requestedSubscriptionCount,
330
+ dedupe_rows: request.dedupeRows === true,
331
+ },
332
+ },
333
+ async (span) => {
334
+ try {
335
+ // Validate and sanitize request limits
336
+ const limitCommits = sanitizeLimit(request.limitCommits, 50, 1, 500);
337
+ const limitSnapshotRows = sanitizeLimit(
338
+ request.limitSnapshotRows,
339
+ 1000,
340
+ 1,
341
+ 5000
342
+ );
343
+ const maxSnapshotPages = sanitizeLimit(
344
+ request.maxSnapshotPages,
345
+ 1,
346
+ 1,
347
+ 50
348
+ );
349
+ const dedupeRows = request.dedupeRows === true;
350
+
351
+ // Resolve effective scopes for each subscription
352
+ const resolved = await resolveEffectiveScopesForSubscriptions({
353
+ db,
354
+ actorId: args.actorId,
355
+ subscriptions: request.subscriptions ?? [],
356
+ handlers: args.handlers,
157
357
  });
158
- continue;
159
- }
160
-
161
- const effectiveScopes = sub.scopes;
162
- activeSubscriptions.push({ scopes: effectiveScopes });
163
-
164
- const needsBootstrap =
165
- sub.bootstrapState != null ||
166
- cursor < 0 ||
167
- cursor > maxCommitSeq ||
168
- (minCommitSeq > 0 && cursor < minCommitSeq - 1);
169
-
170
- if (needsBootstrap) {
171
- const tables = args.shapes
172
- .getBootstrapOrderFor(sub.shape)
173
- .map((handler) => handler.table);
174
-
175
- const initState: SyncBootstrapState = {
176
- asOfCommitSeq: maxCommitSeq,
177
- tables,
178
- tableIndex: 0,
179
- rowCursor: null,
180
- };
181
-
182
- const requestedState = sub.bootstrapState ?? null;
183
- const state =
184
- requestedState &&
185
- typeof requestedState.asOfCommitSeq === 'number' &&
186
- Array.isArray(requestedState.tables) &&
187
- typeof requestedState.tableIndex === 'number'
188
- ? (requestedState as SyncBootstrapState)
189
- : initState;
190
-
191
- // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
192
- const effectiveState =
193
- state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
194
-
195
- const tableName = effectiveState.tables[effectiveState.tableIndex];
196
-
197
- // No tables (or ran past the end): treat bootstrap as complete.
198
- if (!tableName) {
199
- subResponses.push({
200
- id: sub.id,
201
- status: 'active',
202
- scopes: effectiveScopes,
203
- bootstrap: true,
204
- bootstrapState: null,
205
- nextCursor: effectiveState.asOfCommitSeq,
206
- commits: [],
207
- snapshots: [],
208
- });
209
- nextCursors.push(effectiveState.asOfCommitSeq);
210
- continue;
211
- }
212
358
 
213
- const snapshots: SyncSnapshot[] = [];
214
- let nextState: SyncBootstrapState | null = effectiveState;
359
+ const result = await dialect.executeInTransaction(db, async (trx) => {
360
+ await dialect.setRepeatableRead(trx);
215
361
 
216
- for (let pageIndex = 0; pageIndex < maxSnapshotPages; pageIndex++) {
217
- if (!nextState) break;
362
+ const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
363
+ partitionId,
364
+ });
365
+ const minCommitSeq = await dialect.readMinCommitSeq(trx, {
366
+ partitionId,
367
+ });
218
368
 
219
- const nextTableName = nextState.tables[nextState.tableIndex];
220
- if (!nextTableName) {
221
- nextState = null;
222
- break;
369
+ const subResponses: SyncPullSubscriptionResponse[] = [];
370
+ const activeSubscriptions: { scopes: ScopeValues }[] = [];
371
+ const nextCursors: number[] = [];
372
+
373
+ // Detect external data changes (synthetic commits from notifyExternalDataChange)
374
+ // Compute minimum cursor across all active subscriptions to scope the query.
375
+ let minSubCursor = Number.MAX_SAFE_INTEGER;
376
+ for (const sub of resolved) {
377
+ if (
378
+ sub.status === 'revoked' ||
379
+ Object.keys(sub.scopes).length === 0
380
+ )
381
+ continue;
382
+ const cursor = Math.max(-1, sub.cursor ?? -1);
383
+ if (cursor >= 0 && cursor < minSubCursor) {
384
+ minSubCursor = cursor;
385
+ }
223
386
  }
224
387
 
225
- const tableHandler = args.shapes.getOrThrow(nextTableName);
226
- const isFirstPage = nextState.rowCursor == null;
227
-
228
- const page = await tableHandler.snapshot(
229
- {
230
- db: trx,
231
- actorId: args.actorId,
232
- scopeValues: effectiveScopes,
233
- cursor: nextState.rowCursor,
234
- limit: limitSnapshotRows,
235
- },
236
- sub.params
237
- );
238
-
239
- const isLastPage = page.nextCursor == null;
240
-
241
- // Always use NDJSON+gzip for bootstrap snapshots
242
- const ttlMs = tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000; // 24h
243
- const nowIso = new Date().toISOString();
244
-
245
- // Use scope hash for caching
246
- const cacheKey = scopesToCacheKey(effectiveScopes);
247
- const cached = await readSnapshotChunkRefByPageKey(trx, {
248
- scopeKey: cacheKey,
249
- scope: nextTableName,
250
- asOfCommitSeq: effectiveState.asOfCommitSeq,
251
- rowCursor: nextState.rowCursor,
252
- rowLimit: limitSnapshotRows,
253
- encoding: 'ndjson',
254
- compression: 'gzip',
255
- nowIso,
256
- });
388
+ const externalDataChanges =
389
+ minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
390
+ ? await readExternalDataChanges(trx, dialect, {
391
+ partitionId,
392
+ afterCursor: minSubCursor,
393
+ })
394
+ : [];
395
+
396
+ for (const sub of resolved) {
397
+ const cursor = Math.max(-1, sub.cursor ?? -1);
398
+ // Validate table handler exists (throws if not registered)
399
+ args.handlers.getOrThrow(sub.table);
400
+
401
+ if (
402
+ sub.status === 'revoked' ||
403
+ Object.keys(sub.scopes).length === 0
404
+ ) {
405
+ subResponses.push({
406
+ id: sub.id,
407
+ status: 'revoked',
408
+ scopes: {},
409
+ bootstrap: false,
410
+ nextCursor: cursor,
411
+ commits: [],
412
+ });
413
+ continue;
414
+ }
257
415
 
258
- let chunkRef = cached;
416
+ const effectiveScopes = sub.scopes;
417
+ activeSubscriptions.push({ scopes: effectiveScopes });
418
+
419
+ const needsBootstrap =
420
+ sub.bootstrapState != null ||
421
+ cursor < 0 ||
422
+ cursor > maxCommitSeq ||
423
+ (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
424
+ externalDataChanges.some(
425
+ (c) => c.commitSeq > cursor && c.tables.includes(sub.table)
426
+ );
427
+
428
+ if (needsBootstrap) {
429
+ const tables = args.handlers
430
+ .getBootstrapOrderFor(sub.table)
431
+ .map((handler) => handler.table);
432
+
433
+ const initState: SyncBootstrapState = {
434
+ asOfCommitSeq: maxCommitSeq,
435
+ tables,
436
+ tableIndex: 0,
437
+ rowCursor: null,
438
+ };
439
+
440
+ const requestedState = sub.bootstrapState ?? null;
441
+ const state =
442
+ requestedState &&
443
+ typeof requestedState.asOfCommitSeq === 'number' &&
444
+ Array.isArray(requestedState.tables) &&
445
+ typeof requestedState.tableIndex === 'number'
446
+ ? (requestedState as SyncBootstrapState)
447
+ : initState;
448
+
449
+ // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
450
+ const effectiveState =
451
+ state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
452
+
453
+ const tableName =
454
+ effectiveState.tables[effectiveState.tableIndex];
455
+
456
+ // No tables (or ran past the end): treat bootstrap as complete.
457
+ if (!tableName) {
458
+ subResponses.push({
459
+ id: sub.id,
460
+ status: 'active',
461
+ scopes: effectiveScopes,
462
+ bootstrap: true,
463
+ bootstrapState: null,
464
+ nextCursor: effectiveState.asOfCommitSeq,
465
+ commits: [],
466
+ snapshots: [],
467
+ });
468
+ nextCursors.push(effectiveState.asOfCommitSeq);
469
+ continue;
470
+ }
471
+
472
+ const snapshots: SyncSnapshot[] = [];
473
+ let nextState: SyncBootstrapState | null = effectiveState;
474
+ const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
475
+
476
+ interface SnapshotBundle {
477
+ table: string;
478
+ startCursor: string | null;
479
+ isFirstPage: boolean;
480
+ isLastPage: boolean;
481
+ pageCount: number;
482
+ ttlMs: number;
483
+ hash: ReturnType<typeof createHash>;
484
+ rowFrameParts: Uint8Array[];
485
+ }
486
+
487
+ const flushSnapshotBundle = async (
488
+ bundle: SnapshotBundle
489
+ ): Promise<void> => {
490
+ const nowIso = new Date().toISOString();
491
+ const bundleRowLimit = Math.max(
492
+ 1,
493
+ limitSnapshotRows * bundle.pageCount
494
+ );
495
+
496
+ const cached = await readSnapshotChunkRefByPageKey(trx, {
497
+ partitionId,
498
+ scopeKey: cacheKey,
499
+ scope: bundle.table,
500
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
501
+ rowCursor: bundle.startCursor,
502
+ rowLimit: bundleRowLimit,
503
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
504
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
505
+ nowIso,
506
+ });
507
+
508
+ let chunkRef = cached;
509
+ if (!chunkRef) {
510
+ const sha256 = bundle.hash.digest('hex');
511
+ const expiresAt = new Date(
512
+ Date.now() + Math.max(1000, bundle.ttlMs)
513
+ ).toISOString();
514
+
515
+ if (args.chunkStorage) {
516
+ if (args.chunkStorage.storeChunkStream) {
517
+ const { stream: bodyStream, byteLength } =
518
+ await compressSnapshotPayloadStream(
519
+ bundle.rowFrameParts
520
+ );
521
+ chunkRef = await args.chunkStorage.storeChunkStream({
522
+ partitionId,
523
+ scopeKey: cacheKey,
524
+ scope: bundle.table,
525
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
526
+ rowCursor: bundle.startCursor,
527
+ rowLimit: bundleRowLimit,
528
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
529
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
530
+ sha256,
531
+ byteLength,
532
+ bodyStream,
533
+ expiresAt,
534
+ });
535
+ } else {
536
+ const compressedBody = await compressSnapshotPayload(
537
+ concatByteChunks(bundle.rowFrameParts)
538
+ );
539
+ chunkRef = await args.chunkStorage.storeChunk({
540
+ partitionId,
541
+ scopeKey: cacheKey,
542
+ scope: bundle.table,
543
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
544
+ rowCursor: bundle.startCursor,
545
+ rowLimit: bundleRowLimit,
546
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
547
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
548
+ sha256,
549
+ body: compressedBody,
550
+ expiresAt,
551
+ });
552
+ }
553
+ } else {
554
+ const compressedBody = await compressSnapshotPayload(
555
+ concatByteChunks(bundle.rowFrameParts)
556
+ );
557
+ const chunkId = randomUUID();
558
+ chunkRef = await insertSnapshotChunk(trx, {
559
+ chunkId,
560
+ partitionId,
561
+ scopeKey: cacheKey,
562
+ scope: bundle.table,
563
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
564
+ rowCursor: bundle.startCursor,
565
+ rowLimit: bundleRowLimit,
566
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
567
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
568
+ sha256,
569
+ body: compressedBody,
570
+ expiresAt,
571
+ });
572
+ }
573
+ }
574
+
575
+ snapshots.push({
576
+ table: bundle.table,
577
+ rows: [],
578
+ chunks: [chunkRef],
579
+ isFirstPage: bundle.isFirstPage,
580
+ isLastPage: bundle.isLastPage,
581
+ });
582
+ };
583
+
584
+ let activeBundle: SnapshotBundle | null = null;
585
+
586
+ for (
587
+ let pageIndex = 0;
588
+ pageIndex < maxSnapshotPages;
589
+ pageIndex++
590
+ ) {
591
+ if (!nextState) break;
592
+
593
+ const nextTableName = nextState.tables[nextState.tableIndex];
594
+ if (!nextTableName) {
595
+ if (activeBundle) {
596
+ activeBundle.isLastPage = true;
597
+ await flushSnapshotBundle(activeBundle);
598
+ activeBundle = null;
599
+ }
600
+ nextState = null;
601
+ break;
602
+ }
603
+
604
+ const tableHandler = args.handlers.getOrThrow(nextTableName);
605
+ if (!activeBundle || activeBundle.table !== nextTableName) {
606
+ if (activeBundle) {
607
+ await flushSnapshotBundle(activeBundle);
608
+ }
609
+ const bundleHash = createHash('sha256');
610
+ const bundleHeader = encodeSnapshotRows([]);
611
+ bundleHash.update(bundleHeader);
612
+ activeBundle = {
613
+ table: nextTableName,
614
+ startCursor: nextState.rowCursor,
615
+ isFirstPage: nextState.rowCursor == null,
616
+ isLastPage: false,
617
+ pageCount: 0,
618
+ ttlMs:
619
+ tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
620
+ hash: bundleHash,
621
+ rowFrameParts: [bundleHeader],
622
+ };
623
+ }
624
+
625
+ const page = await tableHandler.snapshot(
626
+ {
627
+ db: trx,
628
+ actorId: args.actorId,
629
+ scopeValues: effectiveScopes,
630
+ cursor: nextState.rowCursor,
631
+ limit: limitSnapshotRows,
632
+ },
633
+ sub.params
634
+ );
635
+
636
+ const rowFrames = encodeSnapshotRowFrames(page.rows ?? []);
637
+ activeBundle.hash.update(rowFrames);
638
+ activeBundle.rowFrameParts.push(rowFrames);
639
+ activeBundle.pageCount += 1;
640
+
641
+ if (page.nextCursor != null) {
642
+ nextState = { ...nextState, rowCursor: page.nextCursor };
643
+ continue;
644
+ }
645
+
646
+ activeBundle.isLastPage = true;
647
+ await flushSnapshotBundle(activeBundle);
648
+ activeBundle = null;
649
+
650
+ if (nextState.tableIndex + 1 < nextState.tables.length) {
651
+ nextState = {
652
+ ...nextState,
653
+ tableIndex: nextState.tableIndex + 1,
654
+ rowCursor: null,
655
+ };
656
+ continue;
657
+ }
658
+
659
+ nextState = null;
660
+ break;
661
+ }
662
+
663
+ if (activeBundle) {
664
+ await flushSnapshotBundle(activeBundle);
665
+ }
666
+
667
+ subResponses.push({
668
+ id: sub.id,
669
+ status: 'active',
670
+ scopes: effectiveScopes,
671
+ bootstrap: true,
672
+ bootstrapState: nextState,
673
+ nextCursor: effectiveState.asOfCommitSeq,
674
+ commits: [],
675
+ snapshots,
676
+ });
677
+ nextCursors.push(effectiveState.asOfCommitSeq);
678
+ continue;
679
+ }
259
680
 
260
- if (!chunkRef) {
261
- const lines: string[] = [];
262
- for (const r of page.rows ?? []) {
263
- const s = JSON.stringify(r);
264
- lines.push(s === undefined ? 'null' : s);
681
+ // Incremental pull for this subscription
682
+ // Read the commit window for this table up-front so the subscription cursor
683
+ // can advance past commits that don't match the requested scopes.
684
+ const scannedCommitSeqs = await dialect.readCommitSeqsForPull(trx, {
685
+ partitionId,
686
+ cursor,
687
+ limitCommits,
688
+ tables: [sub.table],
689
+ });
690
+ const maxScannedCommitSeq =
691
+ scannedCommitSeqs.length > 0
692
+ ? scannedCommitSeqs[scannedCommitSeqs.length - 1]!
693
+ : cursor;
694
+
695
+ // Collect rows and compute nextCursor in a single pass
696
+ const incrementalRows: Array<{
697
+ commit_seq: number;
698
+ actor_id: string;
699
+ created_at: string;
700
+ change_id: number;
701
+ table: string;
702
+ row_id: string;
703
+ op: 'upsert' | 'delete';
704
+ row_json: unknown | null;
705
+ row_version: number | null;
706
+ scopes: Record<string, string | string[]>;
707
+ }> = [];
708
+
709
+ let nextCursor = cursor;
710
+
711
+ for await (const row of dialect.iterateIncrementalPullRows(trx, {
712
+ partitionId,
713
+ table: sub.table,
714
+ scopes: effectiveScopes,
715
+ cursor,
716
+ limitCommits,
717
+ })) {
718
+ incrementalRows.push(row);
719
+ nextCursor = Math.max(nextCursor, row.commit_seq);
265
720
  }
266
- const ndjson = lines.length > 0 ? `${lines.join('\n')}\n` : '';
267
- const gz = await compressSnapshotNdjson(ndjson);
268
- const sha256 = createHash('sha256').update(ndjson).digest('hex');
269
- const expiresAt = new Date(
270
- Date.now() + Math.max(1000, ttlMs)
271
- ).toISOString();
272
-
273
- // Use external chunk storage if available, otherwise fall back to inline
274
- if (args.chunkStorage) {
275
- chunkRef = await args.chunkStorage.storeChunk({
276
- scopeKey: cacheKey,
277
- scope: nextTableName,
278
- asOfCommitSeq: effectiveState.asOfCommitSeq,
279
- rowCursor: nextState.rowCursor ?? null,
280
- rowLimit: limitSnapshotRows,
281
- encoding: 'ndjson',
282
- compression: 'gzip',
283
- sha256,
284
- body: gz,
285
- expiresAt,
721
+
722
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
723
+
724
+ if (incrementalRows.length === 0) {
725
+ subResponses.push({
726
+ id: sub.id,
727
+ status: 'active',
728
+ scopes: effectiveScopes,
729
+ bootstrap: false,
730
+ nextCursor,
731
+ commits: [],
286
732
  });
287
- } else {
288
- const chunkId = randomUUID();
289
- chunkRef = await insertSnapshotChunk(trx, {
290
- chunkId,
291
- scopeKey: cacheKey,
292
- scope: nextTableName,
293
- asOfCommitSeq: effectiveState.asOfCommitSeq,
294
- rowCursor: nextState.rowCursor,
295
- rowLimit: limitSnapshotRows,
296
- encoding: 'ndjson',
297
- compression: 'gzip',
298
- sha256,
299
- body: gz,
300
- expiresAt,
733
+ nextCursors.push(nextCursor);
734
+ continue;
735
+ }
736
+
737
+ if (dedupeRows) {
738
+ const latestByRowKey = new Map<
739
+ string,
740
+ {
741
+ commitSeq: number;
742
+ createdAt: string;
743
+ actorId: string;
744
+ changeId: number;
745
+ change: SyncChange;
746
+ }
747
+ >();
748
+
749
+ for (const r of incrementalRows) {
750
+ const rowKey = `${r.table}\u0000${r.row_id}`;
751
+ const change: SyncChange = {
752
+ table: r.table,
753
+ row_id: r.row_id,
754
+ op: r.op,
755
+ row_json: r.row_json,
756
+ row_version: r.row_version,
757
+ scopes: dialect.dbToScopes(r.scopes),
758
+ };
759
+
760
+ latestByRowKey.set(rowKey, {
761
+ commitSeq: r.commit_seq,
762
+ createdAt: r.created_at,
763
+ actorId: r.actor_id,
764
+ changeId: r.change_id,
765
+ change,
766
+ });
767
+ }
768
+
769
+ const latest = Array.from(latestByRowKey.values()).sort(
770
+ (a, b) => a.commitSeq - b.commitSeq || a.changeId - b.changeId
771
+ );
772
+
773
+ const commitsBySeq = new Map<number, SyncCommit>();
774
+ for (const item of latest) {
775
+ let commit = commitsBySeq.get(item.commitSeq);
776
+ if (!commit) {
777
+ commit = {
778
+ commitSeq: item.commitSeq,
779
+ createdAt: item.createdAt,
780
+ actorId: item.actorId,
781
+ changes: [],
782
+ };
783
+ commitsBySeq.set(item.commitSeq, commit);
784
+ }
785
+ commit.changes.push(item.change);
786
+ }
787
+
788
+ const commits = Array.from(commitsBySeq.values()).sort(
789
+ (a, b) => a.commitSeq - b.commitSeq
790
+ );
791
+
792
+ subResponses.push({
793
+ id: sub.id,
794
+ status: 'active',
795
+ scopes: effectiveScopes,
796
+ bootstrap: false,
797
+ nextCursor,
798
+ commits,
301
799
  });
800
+ nextCursors.push(nextCursor);
801
+ continue;
302
802
  }
303
- }
304
803
 
305
- snapshots.push({
306
- table: nextTableName,
307
- rows: [],
308
- chunks: [chunkRef],
309
- isFirstPage,
310
- isLastPage,
311
- });
804
+ const commitsBySeq = new Map<number, SyncCommit>();
805
+ const commitSeqs: number[] = [];
806
+
807
+ for (const r of incrementalRows) {
808
+ const seq = r.commit_seq;
809
+ let commit = commitsBySeq.get(seq);
810
+ if (!commit) {
811
+ commit = {
812
+ commitSeq: seq,
813
+ createdAt: r.created_at,
814
+ actorId: r.actor_id,
815
+ changes: [],
816
+ };
817
+ commitsBySeq.set(seq, commit);
818
+ commitSeqs.push(seq);
819
+ }
820
+
821
+ const change: SyncChange = {
822
+ table: r.table,
823
+ row_id: r.row_id,
824
+ op: r.op,
825
+ row_json: r.row_json,
826
+ row_version: r.row_version,
827
+ scopes: dialect.dbToScopes(r.scopes),
828
+ };
829
+ commit.changes.push(change);
830
+ }
312
831
 
313
- if (page.nextCursor != null) {
314
- nextState = { ...nextState, rowCursor: page.nextCursor };
315
- continue;
832
+ const commits: SyncCommit[] = commitSeqs
833
+ .map((seq) => commitsBySeq.get(seq))
834
+ .filter((c): c is SyncCommit => !!c)
835
+ .filter((c) => c.changes.length > 0);
836
+
837
+ subResponses.push({
838
+ id: sub.id,
839
+ status: 'active',
840
+ scopes: effectiveScopes,
841
+ bootstrap: false,
842
+ nextCursor,
843
+ commits,
844
+ });
845
+ nextCursors.push(nextCursor);
316
846
  }
317
847
 
318
- if (nextState.tableIndex + 1 < nextState.tables.length) {
319
- nextState = {
320
- ...nextState,
321
- tableIndex: nextState.tableIndex + 1,
322
- rowCursor: null,
323
- };
324
- continue;
325
- }
848
+ const effectiveScopes = mergeScopes(activeSubscriptions);
849
+ const clientCursor =
850
+ nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
326
851
 
327
- nextState = null;
328
- break;
329
- }
330
-
331
- subResponses.push({
332
- id: sub.id,
333
- status: 'active',
334
- scopes: effectiveScopes,
335
- bootstrap: true,
336
- bootstrapState: nextState,
337
- nextCursor: effectiveState.asOfCommitSeq,
338
- commits: [],
339
- snapshots,
852
+ return {
853
+ response: {
854
+ ok: true as const,
855
+ subscriptions: subResponses,
856
+ },
857
+ effectiveScopes,
858
+ clientCursor,
859
+ };
340
860
  });
341
- nextCursors.push(effectiveState.asOfCommitSeq);
342
- continue;
343
- }
344
861
 
345
- // Incremental pull for this subscription
346
- // Use streaming when available to reduce memory pressure for large pulls
347
- const pullRowStream = dialect.streamIncrementalPullRows
348
- ? dialect.streamIncrementalPullRows(trx, {
349
- table: sub.shape,
350
- scopes: effectiveScopes,
351
- cursor,
352
- limitCommits,
353
- })
354
- : null;
355
-
356
- // Collect rows and compute nextCursor in a single pass
357
- const incrementalRows: Array<{
358
- commit_seq: number;
359
- actor_id: string;
360
- created_at: string;
361
- change_id: number;
362
- table: string;
363
- row_id: string;
364
- op: 'upsert' | 'delete';
365
- row_json: unknown | null;
366
- row_version: number | null;
367
- scopes: Record<string, string | string[]>;
368
- }> = [];
369
-
370
- let nextCursor = cursor;
371
-
372
- if (pullRowStream) {
373
- // Streaming path: process rows as they arrive
374
- for await (const row of pullRowStream) {
375
- incrementalRows.push(row);
376
- nextCursor = Math.max(nextCursor, row.commit_seq);
377
- }
378
- } else {
379
- // Non-streaming fallback: load all rows at once
380
- const rows = await dialect.readIncrementalPullRows(trx, {
381
- table: sub.shape,
382
- scopes: effectiveScopes,
383
- cursor,
384
- limitCommits,
862
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
863
+ const stats = summarizePullResponse(result.response);
864
+
865
+ span.setAttribute('status', 'ok');
866
+ span.setAttribute('duration_ms', durationMs);
867
+ span.setAttribute('subscription_count', stats.subscriptionCount);
868
+ span.setAttribute('commit_count', stats.commitCount);
869
+ span.setAttribute('change_count', stats.changeCount);
870
+ span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
871
+ span.setStatus('ok');
872
+
873
+ recordPullMetrics({
874
+ status: 'ok',
875
+ dedupeRows,
876
+ durationMs,
877
+ stats,
385
878
  });
386
- incrementalRows.push(...rows);
387
- for (const r of incrementalRows) {
388
- nextCursor = Math.max(nextCursor, r.commit_seq);
389
- }
390
- }
391
879
 
392
- if (incrementalRows.length === 0) {
393
- subResponses.push({
394
- id: sub.id,
395
- status: 'active',
396
- scopes: effectiveScopes,
397
- bootstrap: false,
398
- nextCursor: cursor,
399
- commits: [],
880
+ return result;
881
+ } catch (error) {
882
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
883
+
884
+ span.setAttribute('status', 'error');
885
+ span.setAttribute('duration_ms', durationMs);
886
+ span.setStatus('error');
887
+
888
+ recordPullMetrics({
889
+ status: 'error',
890
+ dedupeRows: request.dedupeRows === true,
891
+ durationMs,
892
+ stats: {
893
+ subscriptionCount: 0,
894
+ activeSubscriptionCount: 0,
895
+ revokedSubscriptionCount: 0,
896
+ bootstrapSubscriptionCount: 0,
897
+ commitCount: 0,
898
+ changeCount: 0,
899
+ snapshotPageCount: 0,
900
+ },
400
901
  });
401
- nextCursors.push(cursor);
402
- continue;
403
- }
404
-
405
- if (dedupeRows) {
406
- const latestByRowKey = new Map<
407
- string,
408
- {
409
- commitSeq: number;
410
- createdAt: string;
411
- actorId: string;
412
- changeId: number;
413
- change: SyncChange;
414
- }
415
- >();
416
-
417
- for (const r of incrementalRows) {
418
- const rowKey = `${r.table}\u0000${r.row_id}`;
419
- const change: SyncChange = {
420
- table: r.table,
421
- row_id: r.row_id,
422
- op: r.op,
423
- row_json: r.row_json,
424
- row_version: r.row_version,
425
- scopes: dialect.dbToScopes(r.scopes),
426
- };
427
-
428
- latestByRowKey.set(rowKey, {
429
- commitSeq: r.commit_seq,
430
- createdAt: r.created_at,
431
- actorId: r.actor_id,
432
- changeId: r.change_id,
433
- change,
434
- });
435
- }
436
-
437
- const latest = Array.from(latestByRowKey.values()).sort(
438
- (a, b) => a.commitSeq - b.commitSeq || a.changeId - b.changeId
439
- );
440
-
441
- const commitsBySeq = new Map<number, SyncCommit>();
442
- for (const item of latest) {
443
- let commit = commitsBySeq.get(item.commitSeq);
444
- if (!commit) {
445
- commit = {
446
- commitSeq: item.commitSeq,
447
- createdAt: item.createdAt,
448
- actorId: item.actorId,
449
- changes: [],
450
- };
451
- commitsBySeq.set(item.commitSeq, commit);
452
- }
453
- commit.changes.push(item.change);
454
- }
455
902
 
456
- const commits = Array.from(commitsBySeq.values()).sort(
457
- (a, b) => a.commitSeq - b.commitSeq
458
- );
459
-
460
- subResponses.push({
461
- id: sub.id,
462
- status: 'active',
463
- scopes: effectiveScopes,
464
- bootstrap: false,
465
- nextCursor,
466
- commits,
903
+ captureSyncException(error, {
904
+ event: 'sync.server.pull',
905
+ requestedSubscriptionCount,
906
+ dedupeRows: request.dedupeRows === true,
467
907
  });
468
- nextCursors.push(nextCursor);
469
- continue;
908
+ throw error;
470
909
  }
471
-
472
- const commitsBySeq = new Map<number, SyncCommit>();
473
- const commitSeqs: number[] = [];
474
-
475
- for (const r of incrementalRows) {
476
- const seq = r.commit_seq;
477
- let commit = commitsBySeq.get(seq);
478
- if (!commit) {
479
- commit = {
480
- commitSeq: seq,
481
- createdAt: r.created_at,
482
- actorId: r.actor_id,
483
- changes: [],
484
- };
485
- commitsBySeq.set(seq, commit);
486
- commitSeqs.push(seq);
487
- }
488
-
489
- const change: SyncChange = {
490
- table: r.table,
491
- row_id: r.row_id,
492
- op: r.op,
493
- row_json: r.row_json,
494
- row_version: r.row_version,
495
- scopes: dialect.dbToScopes(r.scopes),
496
- };
497
- commit.changes.push(change);
498
- }
499
-
500
- const commits: SyncCommit[] = commitSeqs
501
- .map((seq) => commitsBySeq.get(seq))
502
- .filter((c): c is SyncCommit => !!c)
503
- .filter((c) => c.changes.length > 0);
504
-
505
- subResponses.push({
506
- id: sub.id,
507
- status: 'active',
508
- scopes: effectiveScopes,
509
- bootstrap: false,
510
- nextCursor,
511
- commits,
512
- });
513
- nextCursors.push(nextCursor);
514
910
  }
515
-
516
- const effectiveScopes = mergeScopes(activeSubscriptions);
517
- const clientCursor =
518
- nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
519
-
520
- return {
521
- response: {
522
- ok: true as const,
523
- subscriptions: subResponses,
524
- },
525
- effectiveScopes,
526
- clientCursor,
527
- };
528
- });
911
+ );
529
912
  }