@syncular/server 0.0.1 → 0.0.2-127

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/dist/pull.js CHANGED
@@ -1,17 +1,68 @@
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 { insertSnapshotChunk, readSnapshotChunkRefByPageKey, } from './snapshot-chunks';
5
- import { resolveEffectiveScopesForSubscriptions } from './subscriptions/resolve';
4
+ import { captureSyncException, countSyncMetric, distributionSyncMetric, encodeSnapshotRowFrames, encodeSnapshotRows, SYNC_SNAPSHOT_CHUNK_COMPRESSION, SYNC_SNAPSHOT_CHUNK_ENCODING, startSyncSpan, } from '@syncular/core';
5
+ import { EXTERNAL_CLIENT_ID } from './notify.js';
6
+ import { insertSnapshotChunk, readSnapshotChunkRefByPageKey, } from './snapshot-chunks.js';
7
+ import { resolveEffectiveScopesForSubscriptions } from './subscriptions/resolve.js';
6
8
  const gzipAsync = promisify(gzip);
7
9
  const ASYNC_GZIP_MIN_BYTES = 64 * 1024;
8
- async function compressSnapshotNdjson(ndjson) {
9
- if (Buffer.byteLength(ndjson) < ASYNC_GZIP_MIN_BYTES) {
10
- return new Uint8Array(gzipSync(ndjson));
10
+ function concatByteChunks(chunks) {
11
+ if (chunks.length === 1) {
12
+ return chunks[0] ?? new Uint8Array();
11
13
  }
12
- const compressed = await gzipAsync(ndjson);
14
+ let total = 0;
15
+ for (const chunk of chunks) {
16
+ total += chunk.length;
17
+ }
18
+ const merged = new Uint8Array(total);
19
+ let offset = 0;
20
+ for (const chunk of chunks) {
21
+ merged.set(chunk, offset);
22
+ offset += chunk.length;
23
+ }
24
+ return merged;
25
+ }
26
+ function bytesToReadableStream(bytes) {
27
+ return new ReadableStream({
28
+ start(controller) {
29
+ controller.enqueue(bytes);
30
+ controller.close();
31
+ },
32
+ });
33
+ }
34
+ function chunksToReadableStream(chunks) {
35
+ return new ReadableStream({
36
+ start(controller) {
37
+ for (const chunk of chunks) {
38
+ controller.enqueue(chunk);
39
+ }
40
+ controller.close();
41
+ },
42
+ });
43
+ }
44
+ async function compressSnapshotPayload(payload) {
45
+ if (payload.byteLength < ASYNC_GZIP_MIN_BYTES) {
46
+ return new Uint8Array(gzipSync(payload));
47
+ }
48
+ const compressed = await gzipAsync(payload);
13
49
  return new Uint8Array(compressed);
14
50
  }
51
+ async function compressSnapshotPayloadStream(chunks) {
52
+ if (typeof CompressionStream !== 'undefined') {
53
+ const source = chunksToReadableStream(chunks);
54
+ const gzipStream = new CompressionStream('gzip');
55
+ return {
56
+ stream: source.pipeThrough(gzipStream),
57
+ };
58
+ }
59
+ const payload = concatByteChunks(chunks);
60
+ const compressed = await compressSnapshotPayload(payload);
61
+ return {
62
+ stream: bytesToReadableStream(compressed),
63
+ byteLength: compressed.length,
64
+ };
65
+ }
15
66
  /**
16
67
  * Generate a stable cache key for snapshot chunks.
17
68
  */
@@ -59,338 +110,538 @@ function mergeScopes(subscriptions) {
59
110
  }
60
111
  return merged;
61
112
  }
113
+ function summarizePullResponse(response) {
114
+ const subscriptions = response.subscriptions ?? [];
115
+ let activeSubscriptionCount = 0;
116
+ let revokedSubscriptionCount = 0;
117
+ let bootstrapSubscriptionCount = 0;
118
+ let commitCount = 0;
119
+ let changeCount = 0;
120
+ let snapshotPageCount = 0;
121
+ for (const sub of subscriptions) {
122
+ if (sub.status === 'revoked') {
123
+ revokedSubscriptionCount += 1;
124
+ }
125
+ else {
126
+ activeSubscriptionCount += 1;
127
+ }
128
+ if (sub.bootstrap) {
129
+ bootstrapSubscriptionCount += 1;
130
+ }
131
+ const commits = sub.commits ?? [];
132
+ commitCount += commits.length;
133
+ for (const commit of commits) {
134
+ changeCount += commit.changes?.length ?? 0;
135
+ }
136
+ snapshotPageCount += sub.snapshots?.length ?? 0;
137
+ }
138
+ return {
139
+ subscriptionCount: subscriptions.length,
140
+ activeSubscriptionCount,
141
+ revokedSubscriptionCount,
142
+ bootstrapSubscriptionCount,
143
+ commitCount,
144
+ changeCount,
145
+ snapshotPageCount,
146
+ };
147
+ }
148
+ function recordPullMetrics(args) {
149
+ const { status, dedupeRows, durationMs, stats } = args;
150
+ const attributes = {
151
+ status,
152
+ dedupe_rows: dedupeRows,
153
+ };
154
+ countSyncMetric('sync.server.pull.requests', 1, { attributes });
155
+ distributionSyncMetric('sync.server.pull.duration_ms', durationMs, {
156
+ unit: 'millisecond',
157
+ attributes,
158
+ });
159
+ distributionSyncMetric('sync.server.pull.subscriptions', stats.subscriptionCount, { attributes });
160
+ distributionSyncMetric('sync.server.pull.active_subscriptions', stats.activeSubscriptionCount, { attributes });
161
+ distributionSyncMetric('sync.server.pull.revoked_subscriptions', stats.revokedSubscriptionCount, { attributes });
162
+ distributionSyncMetric('sync.server.pull.bootstrap_subscriptions', stats.bootstrapSubscriptionCount, { attributes });
163
+ distributionSyncMetric('sync.server.pull.commits', stats.commitCount, {
164
+ attributes,
165
+ });
166
+ distributionSyncMetric('sync.server.pull.changes', stats.changeCount, {
167
+ attributes,
168
+ });
169
+ distributionSyncMetric('sync.server.pull.snapshot_pages', stats.snapshotPageCount, { attributes });
170
+ }
171
+ /**
172
+ * Read synthetic commits created by notifyExternalDataChange() after a given cursor.
173
+ * Returns commit_seq and affected tables for each external change commit.
174
+ */
175
+ async function readExternalDataChanges(trx, dialect, args) {
176
+ const executor = trx;
177
+ const rows = await executor
178
+ .selectFrom('sync_commits')
179
+ .select(['commit_seq', 'affected_tables'])
180
+ .where('partition_id', '=', args.partitionId)
181
+ .where('client_id', '=', EXTERNAL_CLIENT_ID)
182
+ .where('commit_seq', '>', args.afterCursor)
183
+ .orderBy('commit_seq', 'asc')
184
+ .execute();
185
+ return rows.map((row) => ({
186
+ commitSeq: Number(row.commit_seq),
187
+ tables: dialect.dbToArray(row.affected_tables),
188
+ }));
189
+ }
62
190
  export async function pull(args) {
63
191
  const { request, dialect } = args;
64
192
  const db = args.db;
65
- // Validate and sanitize request limits
66
- const limitCommits = sanitizeLimit(request.limitCommits, 50, 1, 500);
67
- const limitSnapshotRows = sanitizeLimit(request.limitSnapshotRows, 1000, 1, 5000);
68
- const maxSnapshotPages = sanitizeLimit(request.maxSnapshotPages, 1, 1, 50);
69
- const dedupeRows = request.dedupeRows === true;
70
- // Resolve effective scopes for each subscription
71
- const resolved = await resolveEffectiveScopesForSubscriptions({
72
- db,
73
- actorId: args.actorId,
74
- subscriptions: request.subscriptions ?? [],
75
- shapes: args.shapes,
76
- });
77
- return dialect.executeInTransaction(db, async (trx) => {
78
- await dialect.setRepeatableRead(trx);
79
- const maxCommitSeq = await dialect.readMaxCommitSeq(trx);
80
- const minCommitSeq = await dialect.readMinCommitSeq(trx);
81
- const subResponses = [];
82
- const activeSubscriptions = [];
83
- const nextCursors = [];
84
- for (const sub of resolved) {
85
- const cursor = Math.max(-1, sub.cursor ?? -1);
86
- // Validate shape exists (throws if not registered)
87
- args.shapes.getOrThrow(sub.shape);
88
- if (sub.status === 'revoked' || Object.keys(sub.scopes).length === 0) {
89
- subResponses.push({
90
- id: sub.id,
91
- status: 'revoked',
92
- scopes: {},
93
- bootstrap: false,
94
- nextCursor: cursor,
95
- commits: [],
193
+ const partitionId = args.partitionId ?? 'default';
194
+ const requestedSubscriptionCount = Array.isArray(request.subscriptions)
195
+ ? request.subscriptions.length
196
+ : 0;
197
+ const startedAtMs = Date.now();
198
+ return startSyncSpan({
199
+ name: 'sync.server.pull',
200
+ op: 'sync.pull',
201
+ attributes: {
202
+ requested_subscription_count: requestedSubscriptionCount,
203
+ dedupe_rows: request.dedupeRows === true,
204
+ },
205
+ }, async (span) => {
206
+ try {
207
+ // Validate and sanitize request limits
208
+ const limitCommits = sanitizeLimit(request.limitCommits, 50, 1, 500);
209
+ const limitSnapshotRows = sanitizeLimit(request.limitSnapshotRows, 1000, 1, 5000);
210
+ const maxSnapshotPages = sanitizeLimit(request.maxSnapshotPages, 1, 1, 50);
211
+ const dedupeRows = request.dedupeRows === true;
212
+ // Resolve effective scopes for each subscription
213
+ const resolved = await resolveEffectiveScopesForSubscriptions({
214
+ db,
215
+ actorId: args.actorId,
216
+ subscriptions: request.subscriptions ?? [],
217
+ handlers: args.handlers,
218
+ });
219
+ const result = await dialect.executeInTransaction(db, async (trx) => {
220
+ await dialect.setRepeatableRead(trx);
221
+ const maxCommitSeq = await dialect.readMaxCommitSeq(trx, {
222
+ partitionId,
96
223
  });
97
- continue;
98
- }
99
- const effectiveScopes = sub.scopes;
100
- activeSubscriptions.push({ scopes: effectiveScopes });
101
- const needsBootstrap = sub.bootstrapState != null ||
102
- cursor < 0 ||
103
- cursor > maxCommitSeq ||
104
- (minCommitSeq > 0 && cursor < minCommitSeq - 1);
105
- if (needsBootstrap) {
106
- const tables = args.shapes
107
- .getBootstrapOrderFor(sub.shape)
108
- .map((handler) => handler.table);
109
- const initState = {
110
- asOfCommitSeq: maxCommitSeq,
111
- tables,
112
- tableIndex: 0,
113
- rowCursor: null,
114
- };
115
- const requestedState = sub.bootstrapState ?? null;
116
- const state = requestedState &&
117
- typeof requestedState.asOfCommitSeq === 'number' &&
118
- Array.isArray(requestedState.tables) &&
119
- typeof requestedState.tableIndex === 'number'
120
- ? requestedState
121
- : initState;
122
- // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
123
- const effectiveState = state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
124
- const tableName = effectiveState.tables[effectiveState.tableIndex];
125
- // No tables (or ran past the end): treat bootstrap as complete.
126
- if (!tableName) {
127
- subResponses.push({
128
- id: sub.id,
129
- status: 'active',
130
- scopes: effectiveScopes,
131
- bootstrap: true,
132
- bootstrapState: null,
133
- nextCursor: effectiveState.asOfCommitSeq,
134
- commits: [],
135
- snapshots: [],
136
- });
137
- nextCursors.push(effectiveState.asOfCommitSeq);
138
- continue;
224
+ const minCommitSeq = await dialect.readMinCommitSeq(trx, {
225
+ partitionId,
226
+ });
227
+ const subResponses = [];
228
+ const activeSubscriptions = [];
229
+ const nextCursors = [];
230
+ // Detect external data changes (synthetic commits from notifyExternalDataChange)
231
+ // Compute minimum cursor across all active subscriptions to scope the query.
232
+ let minSubCursor = Number.MAX_SAFE_INTEGER;
233
+ for (const sub of resolved) {
234
+ if (sub.status === 'revoked' ||
235
+ Object.keys(sub.scopes).length === 0)
236
+ continue;
237
+ const cursor = Math.max(-1, sub.cursor ?? -1);
238
+ if (cursor >= 0 && cursor < minSubCursor) {
239
+ minSubCursor = cursor;
240
+ }
139
241
  }
140
- const snapshots = [];
141
- let nextState = effectiveState;
142
- for (let pageIndex = 0; pageIndex < maxSnapshotPages; pageIndex++) {
143
- if (!nextState)
144
- break;
145
- const nextTableName = nextState.tables[nextState.tableIndex];
146
- if (!nextTableName) {
147
- nextState = null;
148
- break;
242
+ const externalDataChanges = minSubCursor < Number.MAX_SAFE_INTEGER && minSubCursor >= 0
243
+ ? await readExternalDataChanges(trx, dialect, {
244
+ partitionId,
245
+ afterCursor: minSubCursor,
246
+ })
247
+ : [];
248
+ for (const sub of resolved) {
249
+ const cursor = Math.max(-1, sub.cursor ?? -1);
250
+ // Validate table handler exists (throws if not registered)
251
+ args.handlers.getOrThrow(sub.table);
252
+ if (sub.status === 'revoked' ||
253
+ Object.keys(sub.scopes).length === 0) {
254
+ subResponses.push({
255
+ id: sub.id,
256
+ status: 'revoked',
257
+ scopes: {},
258
+ bootstrap: false,
259
+ nextCursor: cursor,
260
+ commits: [],
261
+ });
262
+ continue;
149
263
  }
150
- const tableHandler = args.shapes.getOrThrow(nextTableName);
151
- const isFirstPage = nextState.rowCursor == null;
152
- const page = await tableHandler.snapshot({
153
- db: trx,
154
- actorId: args.actorId,
155
- scopeValues: effectiveScopes,
156
- cursor: nextState.rowCursor,
157
- limit: limitSnapshotRows,
158
- }, sub.params);
159
- const isLastPage = page.nextCursor == null;
160
- // Always use NDJSON+gzip for bootstrap snapshots
161
- const ttlMs = tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000; // 24h
162
- const nowIso = new Date().toISOString();
163
- // Use scope hash for caching
164
- const cacheKey = scopesToCacheKey(effectiveScopes);
165
- const cached = await readSnapshotChunkRefByPageKey(trx, {
166
- scopeKey: cacheKey,
167
- scope: nextTableName,
168
- asOfCommitSeq: effectiveState.asOfCommitSeq,
169
- rowCursor: nextState.rowCursor,
170
- rowLimit: limitSnapshotRows,
171
- encoding: 'ndjson',
172
- compression: 'gzip',
173
- nowIso,
174
- });
175
- let chunkRef = cached;
176
- if (!chunkRef) {
177
- const lines = [];
178
- for (const r of page.rows ?? []) {
179
- const s = JSON.stringify(r);
180
- lines.push(s === undefined ? 'null' : s);
181
- }
182
- const ndjson = lines.length > 0 ? `${lines.join('\n')}\n` : '';
183
- const gz = await compressSnapshotNdjson(ndjson);
184
- const sha256 = createHash('sha256').update(ndjson).digest('hex');
185
- const expiresAt = new Date(Date.now() + Math.max(1000, ttlMs)).toISOString();
186
- // Use external chunk storage if available, otherwise fall back to inline
187
- if (args.chunkStorage) {
188
- chunkRef = await args.chunkStorage.storeChunk({
189
- scopeKey: cacheKey,
190
- scope: nextTableName,
191
- asOfCommitSeq: effectiveState.asOfCommitSeq,
192
- rowCursor: nextState.rowCursor ?? null,
193
- rowLimit: limitSnapshotRows,
194
- encoding: 'ndjson',
195
- compression: 'gzip',
196
- sha256,
197
- body: gz,
198
- expiresAt,
264
+ const effectiveScopes = sub.scopes;
265
+ activeSubscriptions.push({ scopes: effectiveScopes });
266
+ const needsBootstrap = sub.bootstrapState != null ||
267
+ cursor < 0 ||
268
+ cursor > maxCommitSeq ||
269
+ (minCommitSeq > 0 && cursor < minCommitSeq - 1) ||
270
+ externalDataChanges.some((c) => c.commitSeq > cursor && c.tables.includes(sub.table));
271
+ if (needsBootstrap) {
272
+ const tables = args.handlers
273
+ .getBootstrapOrderFor(sub.table)
274
+ .map((handler) => handler.table);
275
+ const initState = {
276
+ asOfCommitSeq: maxCommitSeq,
277
+ tables,
278
+ tableIndex: 0,
279
+ rowCursor: null,
280
+ };
281
+ const requestedState = sub.bootstrapState ?? null;
282
+ const state = requestedState &&
283
+ typeof requestedState.asOfCommitSeq === 'number' &&
284
+ Array.isArray(requestedState.tables) &&
285
+ typeof requestedState.tableIndex === 'number'
286
+ ? requestedState
287
+ : initState;
288
+ // If the bootstrap state's asOfCommitSeq is no longer catch-up-able, restart bootstrap.
289
+ const effectiveState = state.asOfCommitSeq < minCommitSeq - 1 ? initState : state;
290
+ const tableName = effectiveState.tables[effectiveState.tableIndex];
291
+ // No tables (or ran past the end): treat bootstrap as complete.
292
+ if (!tableName) {
293
+ subResponses.push({
294
+ id: sub.id,
295
+ status: 'active',
296
+ scopes: effectiveScopes,
297
+ bootstrap: true,
298
+ bootstrapState: null,
299
+ nextCursor: effectiveState.asOfCommitSeq,
300
+ commits: [],
301
+ snapshots: [],
199
302
  });
303
+ nextCursors.push(effectiveState.asOfCommitSeq);
304
+ continue;
200
305
  }
201
- else {
202
- const chunkId = randomUUID();
203
- chunkRef = await insertSnapshotChunk(trx, {
204
- chunkId,
306
+ const snapshots = [];
307
+ let nextState = effectiveState;
308
+ const cacheKey = `${partitionId}:${scopesToCacheKey(effectiveScopes)}`;
309
+ const flushSnapshotBundle = async (bundle) => {
310
+ const nowIso = new Date().toISOString();
311
+ const bundleRowLimit = Math.max(1, limitSnapshotRows * bundle.pageCount);
312
+ const cached = await readSnapshotChunkRefByPageKey(trx, {
313
+ partitionId,
205
314
  scopeKey: cacheKey,
206
- scope: nextTableName,
315
+ scope: bundle.table,
207
316
  asOfCommitSeq: effectiveState.asOfCommitSeq,
208
- rowCursor: nextState.rowCursor,
209
- rowLimit: limitSnapshotRows,
210
- encoding: 'ndjson',
211
- compression: 'gzip',
212
- sha256,
213
- body: gz,
214
- expiresAt,
317
+ rowCursor: bundle.startCursor,
318
+ rowLimit: bundleRowLimit,
319
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
320
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
321
+ nowIso,
322
+ });
323
+ let chunkRef = cached;
324
+ if (!chunkRef) {
325
+ const sha256 = bundle.hash.digest('hex');
326
+ const expiresAt = new Date(Date.now() + Math.max(1000, bundle.ttlMs)).toISOString();
327
+ if (args.chunkStorage) {
328
+ if (args.chunkStorage.storeChunkStream) {
329
+ const { stream: bodyStream, byteLength } = await compressSnapshotPayloadStream(bundle.rowFrameParts);
330
+ chunkRef = await args.chunkStorage.storeChunkStream({
331
+ partitionId,
332
+ scopeKey: cacheKey,
333
+ scope: bundle.table,
334
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
335
+ rowCursor: bundle.startCursor,
336
+ rowLimit: bundleRowLimit,
337
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
338
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
339
+ sha256,
340
+ byteLength,
341
+ bodyStream,
342
+ expiresAt,
343
+ });
344
+ }
345
+ else {
346
+ const compressedBody = await compressSnapshotPayload(concatByteChunks(bundle.rowFrameParts));
347
+ chunkRef = await args.chunkStorage.storeChunk({
348
+ partitionId,
349
+ scopeKey: cacheKey,
350
+ scope: bundle.table,
351
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
352
+ rowCursor: bundle.startCursor,
353
+ rowLimit: bundleRowLimit,
354
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
355
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
356
+ sha256,
357
+ body: compressedBody,
358
+ expiresAt,
359
+ });
360
+ }
361
+ }
362
+ else {
363
+ const compressedBody = await compressSnapshotPayload(concatByteChunks(bundle.rowFrameParts));
364
+ const chunkId = randomUUID();
365
+ chunkRef = await insertSnapshotChunk(trx, {
366
+ chunkId,
367
+ partitionId,
368
+ scopeKey: cacheKey,
369
+ scope: bundle.table,
370
+ asOfCommitSeq: effectiveState.asOfCommitSeq,
371
+ rowCursor: bundle.startCursor,
372
+ rowLimit: bundleRowLimit,
373
+ encoding: SYNC_SNAPSHOT_CHUNK_ENCODING,
374
+ compression: SYNC_SNAPSHOT_CHUNK_COMPRESSION,
375
+ sha256,
376
+ body: compressedBody,
377
+ expiresAt,
378
+ });
379
+ }
380
+ }
381
+ snapshots.push({
382
+ table: bundle.table,
383
+ rows: [],
384
+ chunks: [chunkRef],
385
+ isFirstPage: bundle.isFirstPage,
386
+ isLastPage: bundle.isLastPage,
215
387
  });
388
+ };
389
+ let activeBundle = null;
390
+ for (let pageIndex = 0; pageIndex < maxSnapshotPages; pageIndex++) {
391
+ if (!nextState)
392
+ break;
393
+ const nextTableName = nextState.tables[nextState.tableIndex];
394
+ if (!nextTableName) {
395
+ if (activeBundle) {
396
+ activeBundle.isLastPage = true;
397
+ await flushSnapshotBundle(activeBundle);
398
+ activeBundle = null;
399
+ }
400
+ nextState = null;
401
+ break;
402
+ }
403
+ const tableHandler = args.handlers.getOrThrow(nextTableName);
404
+ if (!activeBundle || activeBundle.table !== nextTableName) {
405
+ if (activeBundle) {
406
+ await flushSnapshotBundle(activeBundle);
407
+ }
408
+ const bundleHash = createHash('sha256');
409
+ const bundleHeader = encodeSnapshotRows([]);
410
+ bundleHash.update(bundleHeader);
411
+ activeBundle = {
412
+ table: nextTableName,
413
+ startCursor: nextState.rowCursor,
414
+ isFirstPage: nextState.rowCursor == null,
415
+ isLastPage: false,
416
+ pageCount: 0,
417
+ ttlMs: tableHandler.snapshotChunkTtlMs ?? 24 * 60 * 60 * 1000,
418
+ hash: bundleHash,
419
+ rowFrameParts: [bundleHeader],
420
+ };
421
+ }
422
+ const page = await tableHandler.snapshot({
423
+ db: trx,
424
+ actorId: args.actorId,
425
+ scopeValues: effectiveScopes,
426
+ cursor: nextState.rowCursor,
427
+ limit: limitSnapshotRows,
428
+ }, sub.params);
429
+ const rowFrames = encodeSnapshotRowFrames(page.rows ?? []);
430
+ activeBundle.hash.update(rowFrames);
431
+ activeBundle.rowFrameParts.push(rowFrames);
432
+ activeBundle.pageCount += 1;
433
+ if (page.nextCursor != null) {
434
+ nextState = { ...nextState, rowCursor: page.nextCursor };
435
+ continue;
436
+ }
437
+ activeBundle.isLastPage = true;
438
+ await flushSnapshotBundle(activeBundle);
439
+ activeBundle = null;
440
+ if (nextState.tableIndex + 1 < nextState.tables.length) {
441
+ nextState = {
442
+ ...nextState,
443
+ tableIndex: nextState.tableIndex + 1,
444
+ rowCursor: null,
445
+ };
446
+ continue;
447
+ }
448
+ nextState = null;
449
+ break;
216
450
  }
451
+ if (activeBundle) {
452
+ await flushSnapshotBundle(activeBundle);
453
+ }
454
+ subResponses.push({
455
+ id: sub.id,
456
+ status: 'active',
457
+ scopes: effectiveScopes,
458
+ bootstrap: true,
459
+ bootstrapState: nextState,
460
+ nextCursor: effectiveState.asOfCommitSeq,
461
+ commits: [],
462
+ snapshots,
463
+ });
464
+ nextCursors.push(effectiveState.asOfCommitSeq);
465
+ continue;
217
466
  }
218
- snapshots.push({
219
- table: nextTableName,
220
- rows: [],
221
- chunks: [chunkRef],
222
- isFirstPage,
223
- isLastPage,
467
+ // Incremental pull for this subscription
468
+ // Read the commit window for this table up-front so the subscription cursor
469
+ // can advance past commits that don't match the requested scopes.
470
+ const scannedCommitSeqs = await dialect.readCommitSeqsForPull(trx, {
471
+ partitionId,
472
+ cursor,
473
+ limitCommits,
474
+ tables: [sub.table],
224
475
  });
225
- if (page.nextCursor != null) {
226
- nextState = { ...nextState, rowCursor: page.nextCursor };
476
+ const maxScannedCommitSeq = scannedCommitSeqs.length > 0
477
+ ? scannedCommitSeqs[scannedCommitSeqs.length - 1]
478
+ : cursor;
479
+ // Collect rows and compute nextCursor in a single pass
480
+ const incrementalRows = [];
481
+ let nextCursor = cursor;
482
+ for await (const row of dialect.iterateIncrementalPullRows(trx, {
483
+ partitionId,
484
+ table: sub.table,
485
+ scopes: effectiveScopes,
486
+ cursor,
487
+ limitCommits,
488
+ })) {
489
+ incrementalRows.push(row);
490
+ nextCursor = Math.max(nextCursor, row.commit_seq);
491
+ }
492
+ nextCursor = Math.max(nextCursor, maxScannedCommitSeq);
493
+ if (incrementalRows.length === 0) {
494
+ subResponses.push({
495
+ id: sub.id,
496
+ status: 'active',
497
+ scopes: effectiveScopes,
498
+ bootstrap: false,
499
+ nextCursor,
500
+ commits: [],
501
+ });
502
+ nextCursors.push(nextCursor);
227
503
  continue;
228
504
  }
229
- if (nextState.tableIndex + 1 < nextState.tables.length) {
230
- nextState = {
231
- ...nextState,
232
- tableIndex: nextState.tableIndex + 1,
233
- rowCursor: null,
234
- };
505
+ if (dedupeRows) {
506
+ const latestByRowKey = new Map();
507
+ for (const r of incrementalRows) {
508
+ const rowKey = `${r.table}\u0000${r.row_id}`;
509
+ const change = {
510
+ table: r.table,
511
+ row_id: r.row_id,
512
+ op: r.op,
513
+ row_json: r.row_json,
514
+ row_version: r.row_version,
515
+ scopes: dialect.dbToScopes(r.scopes),
516
+ };
517
+ latestByRowKey.set(rowKey, {
518
+ commitSeq: r.commit_seq,
519
+ createdAt: r.created_at,
520
+ actorId: r.actor_id,
521
+ changeId: r.change_id,
522
+ change,
523
+ });
524
+ }
525
+ const latest = Array.from(latestByRowKey.values()).sort((a, b) => a.commitSeq - b.commitSeq || a.changeId - b.changeId);
526
+ const commitsBySeq = new Map();
527
+ for (const item of latest) {
528
+ let commit = commitsBySeq.get(item.commitSeq);
529
+ if (!commit) {
530
+ commit = {
531
+ commitSeq: item.commitSeq,
532
+ createdAt: item.createdAt,
533
+ actorId: item.actorId,
534
+ changes: [],
535
+ };
536
+ commitsBySeq.set(item.commitSeq, commit);
537
+ }
538
+ commit.changes.push(item.change);
539
+ }
540
+ const commits = Array.from(commitsBySeq.values()).sort((a, b) => a.commitSeq - b.commitSeq);
541
+ subResponses.push({
542
+ id: sub.id,
543
+ status: 'active',
544
+ scopes: effectiveScopes,
545
+ bootstrap: false,
546
+ nextCursor,
547
+ commits,
548
+ });
549
+ nextCursors.push(nextCursor);
235
550
  continue;
236
551
  }
237
- nextState = null;
238
- break;
239
- }
240
- subResponses.push({
241
- id: sub.id,
242
- status: 'active',
243
- scopes: effectiveScopes,
244
- bootstrap: true,
245
- bootstrapState: nextState,
246
- nextCursor: effectiveState.asOfCommitSeq,
247
- commits: [],
248
- snapshots,
249
- });
250
- nextCursors.push(effectiveState.asOfCommitSeq);
251
- continue;
252
- }
253
- // Incremental pull for this subscription
254
- // Use streaming when available to reduce memory pressure for large pulls
255
- const pullRowStream = dialect.streamIncrementalPullRows
256
- ? dialect.streamIncrementalPullRows(trx, {
257
- table: sub.shape,
258
- scopes: effectiveScopes,
259
- cursor,
260
- limitCommits,
261
- })
262
- : null;
263
- // Collect rows and compute nextCursor in a single pass
264
- const incrementalRows = [];
265
- let nextCursor = cursor;
266
- if (pullRowStream) {
267
- // Streaming path: process rows as they arrive
268
- for await (const row of pullRowStream) {
269
- incrementalRows.push(row);
270
- nextCursor = Math.max(nextCursor, row.commit_seq);
271
- }
272
- }
273
- else {
274
- // Non-streaming fallback: load all rows at once
275
- const rows = await dialect.readIncrementalPullRows(trx, {
276
- table: sub.shape,
277
- scopes: effectiveScopes,
278
- cursor,
279
- limitCommits,
280
- });
281
- incrementalRows.push(...rows);
282
- for (const r of incrementalRows) {
283
- nextCursor = Math.max(nextCursor, r.commit_seq);
284
- }
285
- }
286
- if (incrementalRows.length === 0) {
287
- subResponses.push({
288
- id: sub.id,
289
- status: 'active',
290
- scopes: effectiveScopes,
291
- bootstrap: false,
292
- nextCursor: cursor,
293
- commits: [],
294
- });
295
- nextCursors.push(cursor);
296
- continue;
297
- }
298
- if (dedupeRows) {
299
- const latestByRowKey = new Map();
300
- for (const r of incrementalRows) {
301
- const rowKey = `${r.table}\u0000${r.row_id}`;
302
- const change = {
303
- table: r.table,
304
- row_id: r.row_id,
305
- op: r.op,
306
- row_json: r.row_json,
307
- row_version: r.row_version,
308
- scopes: dialect.dbToScopes(r.scopes),
309
- };
310
- latestByRowKey.set(rowKey, {
311
- commitSeq: r.commit_seq,
312
- createdAt: r.created_at,
313
- actorId: r.actor_id,
314
- changeId: r.change_id,
315
- change,
316
- });
317
- }
318
- const latest = Array.from(latestByRowKey.values()).sort((a, b) => a.commitSeq - b.commitSeq || a.changeId - b.changeId);
319
- const commitsBySeq = new Map();
320
- for (const item of latest) {
321
- let commit = commitsBySeq.get(item.commitSeq);
322
- if (!commit) {
323
- commit = {
324
- commitSeq: item.commitSeq,
325
- createdAt: item.createdAt,
326
- actorId: item.actorId,
327
- changes: [],
552
+ const commitsBySeq = new Map();
553
+ const commitSeqs = [];
554
+ for (const r of incrementalRows) {
555
+ const seq = r.commit_seq;
556
+ let commit = commitsBySeq.get(seq);
557
+ if (!commit) {
558
+ commit = {
559
+ commitSeq: seq,
560
+ createdAt: r.created_at,
561
+ actorId: r.actor_id,
562
+ changes: [],
563
+ };
564
+ commitsBySeq.set(seq, commit);
565
+ commitSeqs.push(seq);
566
+ }
567
+ const change = {
568
+ table: r.table,
569
+ row_id: r.row_id,
570
+ op: r.op,
571
+ row_json: r.row_json,
572
+ row_version: r.row_version,
573
+ scopes: dialect.dbToScopes(r.scopes),
328
574
  };
329
- commitsBySeq.set(item.commitSeq, commit);
575
+ commit.changes.push(change);
330
576
  }
331
- commit.changes.push(item.change);
332
- }
333
- const commits = Array.from(commitsBySeq.values()).sort((a, b) => a.commitSeq - b.commitSeq);
334
- subResponses.push({
335
- id: sub.id,
336
- status: 'active',
337
- scopes: effectiveScopes,
338
- bootstrap: false,
339
- nextCursor,
340
- commits,
341
- });
342
- nextCursors.push(nextCursor);
343
- continue;
344
- }
345
- const commitsBySeq = new Map();
346
- const commitSeqs = [];
347
- for (const r of incrementalRows) {
348
- const seq = r.commit_seq;
349
- let commit = commitsBySeq.get(seq);
350
- if (!commit) {
351
- commit = {
352
- commitSeq: seq,
353
- createdAt: r.created_at,
354
- actorId: r.actor_id,
355
- changes: [],
356
- };
357
- commitsBySeq.set(seq, commit);
358
- commitSeqs.push(seq);
577
+ const commits = commitSeqs
578
+ .map((seq) => commitsBySeq.get(seq))
579
+ .filter((c) => !!c)
580
+ .filter((c) => c.changes.length > 0);
581
+ subResponses.push({
582
+ id: sub.id,
583
+ status: 'active',
584
+ scopes: effectiveScopes,
585
+ bootstrap: false,
586
+ nextCursor,
587
+ commits,
588
+ });
589
+ nextCursors.push(nextCursor);
359
590
  }
360
- const change = {
361
- table: r.table,
362
- row_id: r.row_id,
363
- op: r.op,
364
- row_json: r.row_json,
365
- row_version: r.row_version,
366
- scopes: dialect.dbToScopes(r.scopes),
591
+ const effectiveScopes = mergeScopes(activeSubscriptions);
592
+ const clientCursor = nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
593
+ return {
594
+ response: {
595
+ ok: true,
596
+ subscriptions: subResponses,
597
+ },
598
+ effectiveScopes,
599
+ clientCursor,
367
600
  };
368
- commit.changes.push(change);
369
- }
370
- const commits = commitSeqs
371
- .map((seq) => commitsBySeq.get(seq))
372
- .filter((c) => !!c)
373
- .filter((c) => c.changes.length > 0);
374
- subResponses.push({
375
- id: sub.id,
376
- status: 'active',
377
- scopes: effectiveScopes,
378
- bootstrap: false,
379
- nextCursor,
380
- commits,
381
601
  });
382
- nextCursors.push(nextCursor);
602
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
603
+ const stats = summarizePullResponse(result.response);
604
+ span.setAttribute('status', 'ok');
605
+ span.setAttribute('duration_ms', durationMs);
606
+ span.setAttribute('subscription_count', stats.subscriptionCount);
607
+ span.setAttribute('commit_count', stats.commitCount);
608
+ span.setAttribute('change_count', stats.changeCount);
609
+ span.setAttribute('snapshot_page_count', stats.snapshotPageCount);
610
+ span.setStatus('ok');
611
+ recordPullMetrics({
612
+ status: 'ok',
613
+ dedupeRows,
614
+ durationMs,
615
+ stats,
616
+ });
617
+ return result;
618
+ }
619
+ catch (error) {
620
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
621
+ span.setAttribute('status', 'error');
622
+ span.setAttribute('duration_ms', durationMs);
623
+ span.setStatus('error');
624
+ recordPullMetrics({
625
+ status: 'error',
626
+ dedupeRows: request.dedupeRows === true,
627
+ durationMs,
628
+ stats: {
629
+ subscriptionCount: 0,
630
+ activeSubscriptionCount: 0,
631
+ revokedSubscriptionCount: 0,
632
+ bootstrapSubscriptionCount: 0,
633
+ commitCount: 0,
634
+ changeCount: 0,
635
+ snapshotPageCount: 0,
636
+ },
637
+ });
638
+ captureSyncException(error, {
639
+ event: 'sync.server.pull',
640
+ requestedSubscriptionCount,
641
+ dedupeRows: request.dedupeRows === true,
642
+ });
643
+ throw error;
383
644
  }
384
- const effectiveScopes = mergeScopes(activeSubscriptions);
385
- const clientCursor = nextCursors.length > 0 ? Math.min(...nextCursors) : maxCommitSeq;
386
- return {
387
- response: {
388
- ok: true,
389
- subscriptions: subResponses,
390
- },
391
- effectiveScopes,
392
- clientCursor,
393
- };
394
645
  });
395
646
  }
396
647
  //# sourceMappingURL=pull.js.map