@syncular/server 0.0.1-83 → 0.0.1-88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/push.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { captureSyncException, countSyncMetric, distributionSyncMetric, startSyncSpan, } from '@syncular/core';
1
2
  import { sql } from 'kysely';
2
3
  class RejectCommitError extends Error {
3
4
  response;
@@ -36,95 +37,61 @@ function scopeKeysFromEmitted(emitted) {
36
37
  }
37
38
  return Array.from(keys);
38
39
  }
40
+ function recordPushMetrics(args) {
41
+ const { status, durationMs, operationCount, emittedChangeCount, affectedTableCount, } = args;
42
+ countSyncMetric('sync.server.push.requests', 1, {
43
+ attributes: { status },
44
+ });
45
+ countSyncMetric('sync.server.push.operations', operationCount, {
46
+ attributes: { status },
47
+ });
48
+ distributionSyncMetric('sync.server.push.duration_ms', durationMs, {
49
+ unit: 'millisecond',
50
+ attributes: { status },
51
+ });
52
+ distributionSyncMetric('sync.server.push.emitted_changes', emittedChangeCount, {
53
+ attributes: { status },
54
+ });
55
+ distributionSyncMetric('sync.server.push.affected_tables', affectedTableCount, {
56
+ attributes: { status },
57
+ });
58
+ }
39
59
  export async function pushCommit(args) {
40
60
  const { request, dialect } = args;
41
61
  const db = args.db;
42
62
  const partitionId = args.partitionId ?? 'default';
43
- if (!request.clientId || !request.clientCommitId) {
44
- return {
45
- response: {
46
- ok: true,
47
- status: 'rejected',
48
- results: [
49
- {
50
- opIndex: 0,
51
- status: 'error',
52
- error: 'INVALID_REQUEST',
53
- code: 'INVALID_REQUEST',
54
- retriable: false,
55
- },
56
- ],
57
- },
58
- affectedTables: [],
59
- scopeKeys: [],
60
- emittedChanges: [],
63
+ const requestedOps = Array.isArray(request.operations)
64
+ ? request.operations
65
+ : [];
66
+ const operationCount = requestedOps.length;
67
+ const startedAtMs = Date.now();
68
+ return startSyncSpan({
69
+ name: 'sync.server.push',
70
+ op: 'sync.push',
71
+ attributes: {
72
+ operation_count: operationCount,
73
+ },
74
+ }, async (span) => {
75
+ const finalizeResult = (result) => {
76
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
77
+ const status = result.response.status;
78
+ span.setAttribute('status', status);
79
+ span.setAttribute('duration_ms', durationMs);
80
+ span.setAttribute('emitted_change_count', result.emittedChanges.length);
81
+ span.setAttribute('affected_table_count', result.affectedTables.length);
82
+ span.setStatus('ok');
83
+ recordPushMetrics({
84
+ status,
85
+ durationMs,
86
+ operationCount,
87
+ emittedChangeCount: result.emittedChanges.length,
88
+ affectedTableCount: result.affectedTables.length,
89
+ });
90
+ return result;
61
91
  };
62
- }
63
- const ops = request.operations ?? [];
64
- if (!Array.isArray(ops) || ops.length === 0) {
65
- return {
66
- response: {
67
- ok: true,
68
- status: 'rejected',
69
- results: [
70
- {
71
- opIndex: 0,
72
- status: 'error',
73
- error: 'EMPTY_COMMIT',
74
- code: 'EMPTY_COMMIT',
75
- retriable: false,
76
- },
77
- ],
78
- },
79
- affectedTables: [],
80
- scopeKeys: [],
81
- emittedChanges: [],
82
- };
83
- }
84
- return dialect.executeInTransaction(db, async (trx) => {
85
- const syncTrx = trx;
86
- // Clean up any stale commit row with null result_json.
87
- // This can happen when a previous push inserted the commit row but crashed
88
- // before writing the result (e.g., on D1 without transaction support).
89
- await syncTrx
90
- .deleteFrom('sync_commits')
91
- .where('partition_id', '=', partitionId)
92
- .where('client_id', '=', request.clientId)
93
- .where('client_commit_id', '=', request.clientCommitId)
94
- .where('result_json', 'is', null)
95
- .execute();
96
- // Insert commit row (idempotency key)
97
- const commitRow = {
98
- partition_id: partitionId,
99
- actor_id: args.actorId,
100
- client_id: request.clientId,
101
- client_commit_id: request.clientCommitId,
102
- meta: null,
103
- result_json: null,
104
- };
105
- const insertResult = await syncTrx
106
- .insertInto('sync_commits')
107
- .values(commitRow)
108
- .onConflict((oc) => oc
109
- .columns(['partition_id', 'client_id', 'client_commit_id'])
110
- .doNothing())
111
- .executeTakeFirstOrThrow();
112
- const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
113
- if (insertedRows === 0) {
114
- // Existing commit: return cached response (applied or rejected)
115
- // Use forUpdate() for row locking on databases that support it
116
- let query = syncTrx.selectFrom('sync_commits')
117
- .selectAll()
118
- .where('partition_id', '=', partitionId)
119
- .where('client_id', '=', request.clientId)
120
- .where('client_commit_id', '=', request.clientCommitId);
121
- if (dialect.supportsForUpdate) {
122
- query = query.forUpdate();
123
- }
124
- const existing = await query.executeTakeFirstOrThrow();
125
- const cached = existing.result_json;
126
- if (!cached || cached.ok !== true) {
127
- return {
92
+ try {
93
+ if (!request.clientId || !request.clientCommitId) {
94
+ return finalizeResult({
128
95
  response: {
129
96
  ok: true,
130
97
  status: 'rejected',
@@ -132,199 +99,313 @@ export async function pushCommit(args) {
132
99
  {
133
100
  opIndex: 0,
134
101
  status: 'error',
135
- error: 'IDEMPOTENCY_CACHE_MISS',
136
- code: 'INTERNAL',
137
- retriable: true,
102
+ error: 'INVALID_REQUEST',
103
+ code: 'INVALID_REQUEST',
104
+ retriable: false,
138
105
  },
139
106
  ],
140
107
  },
141
108
  affectedTables: [],
142
109
  scopeKeys: [],
143
110
  emittedChanges: [],
144
- };
111
+ });
145
112
  }
146
- const base = {
147
- ...cached,
148
- commitSeq: Number(existing.commit_seq),
149
- };
150
- if (cached.status === 'applied') {
151
- const tablesFromDb = dialect.dbToArray(existing.affected_tables);
152
- return {
153
- response: { ...base, status: 'cached' },
154
- affectedTables: tablesFromDb.length > 0
155
- ? tablesFromDb
156
- : await readCommitAffectedTables(trx, dialect, Number(existing.commit_seq), partitionId),
113
+ const ops = request.operations ?? [];
114
+ if (!Array.isArray(ops) || ops.length === 0) {
115
+ return finalizeResult({
116
+ response: {
117
+ ok: true,
118
+ status: 'rejected',
119
+ results: [
120
+ {
121
+ opIndex: 0,
122
+ status: 'error',
123
+ error: 'EMPTY_COMMIT',
124
+ code: 'EMPTY_COMMIT',
125
+ retriable: false,
126
+ },
127
+ ],
128
+ },
129
+ affectedTables: [],
157
130
  scopeKeys: [],
158
131
  emittedChanges: [],
159
- };
160
- }
161
- return {
162
- response: base,
163
- affectedTables: [],
164
- scopeKeys: [],
165
- emittedChanges: [],
166
- };
167
- }
168
- const insertedCommit = await syncTrx.selectFrom('sync_commits')
169
- .selectAll()
170
- .where('partition_id', '=', partitionId)
171
- .where('client_id', '=', request.clientId)
172
- .where('client_commit_id', '=', request.clientCommitId)
173
- .executeTakeFirstOrThrow();
174
- const commitSeq = Number(insertedCommit.commit_seq);
175
- const commitId = `${request.clientId}:${request.clientCommitId}`;
176
- const savepointName = 'sync_apply';
177
- const useSavepoints = dialect.supportsSavepoints;
178
- let savepointCreated = false;
179
- try {
180
- // Apply the commit under a savepoint so we can roll back app writes on conflict
181
- // while still persisting the commit-level cached response.
182
- if (useSavepoints) {
183
- await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
184
- savepointCreated = true;
132
+ });
185
133
  }
186
- const allEmitted = [];
187
- const results = [];
188
- const affectedTablesSet = new Set();
189
- for (let i = 0; i < ops.length; i++) {
190
- const op = ops[i];
191
- const handler = args.shapes.getOrThrow(op.table);
192
- const applied = await handler.applyOperation({
193
- db: trx,
194
- trx,
195
- actorId: args.actorId,
196
- clientId: request.clientId,
197
- commitId,
198
- schemaVersion: request.schemaVersion,
199
- }, op, i);
200
- if (applied.result.status !== 'applied') {
201
- results.push(applied.result);
202
- throw new RejectCommitError({
134
+ return finalizeResult(await dialect.executeInTransaction(db, async (trx) => {
135
+ const syncTrx = trx;
136
+ // Clean up any stale commit row with null result_json.
137
+ // This can happen when a previous push inserted the commit row but crashed
138
+ // before writing the result (e.g., on D1 without transaction support).
139
+ await syncTrx
140
+ .deleteFrom('sync_commits')
141
+ .where('partition_id', '=', partitionId)
142
+ .where('client_id', '=', request.clientId)
143
+ .where('client_commit_id', '=', request.clientCommitId)
144
+ .where('result_json', 'is', null)
145
+ .execute();
146
+ // Insert commit row (idempotency key)
147
+ const commitRow = {
148
+ partition_id: partitionId,
149
+ actor_id: args.actorId,
150
+ client_id: request.clientId,
151
+ client_commit_id: request.clientCommitId,
152
+ meta: null,
153
+ result_json: null,
154
+ };
155
+ const insertResult = await syncTrx
156
+ .insertInto('sync_commits')
157
+ .values(commitRow)
158
+ .onConflict((oc) => oc
159
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
160
+ .doNothing())
161
+ .executeTakeFirstOrThrow();
162
+ const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
163
+ if (insertedRows === 0) {
164
+ // Existing commit: return cached response (applied or rejected)
165
+ // Use forUpdate() for row locking on databases that support it
166
+ let query = syncTrx.selectFrom('sync_commits')
167
+ .selectAll()
168
+ .where('partition_id', '=', partitionId)
169
+ .where('client_id', '=', request.clientId)
170
+ .where('client_commit_id', '=', request.clientCommitId);
171
+ if (dialect.supportsForUpdate) {
172
+ query = query.forUpdate();
173
+ }
174
+ const existing = await query.executeTakeFirstOrThrow();
175
+ const cached = existing.result_json;
176
+ if (!cached || cached.ok !== true) {
177
+ return {
178
+ response: {
179
+ ok: true,
180
+ status: 'rejected',
181
+ results: [
182
+ {
183
+ opIndex: 0,
184
+ status: 'error',
185
+ error: 'IDEMPOTENCY_CACHE_MISS',
186
+ code: 'INTERNAL',
187
+ retriable: true,
188
+ },
189
+ ],
190
+ },
191
+ affectedTables: [],
192
+ scopeKeys: [],
193
+ emittedChanges: [],
194
+ };
195
+ }
196
+ const base = {
197
+ ...cached,
198
+ commitSeq: Number(existing.commit_seq),
199
+ };
200
+ if (cached.status === 'applied') {
201
+ const tablesFromDb = dialect.dbToArray(existing.affected_tables);
202
+ return {
203
+ response: { ...base, status: 'cached' },
204
+ affectedTables: tablesFromDb.length > 0
205
+ ? tablesFromDb
206
+ : await readCommitAffectedTables(trx, dialect, Number(existing.commit_seq), partitionId),
207
+ scopeKeys: [],
208
+ emittedChanges: [],
209
+ };
210
+ }
211
+ return {
212
+ response: base,
213
+ affectedTables: [],
214
+ scopeKeys: [],
215
+ emittedChanges: [],
216
+ };
217
+ }
218
+ const insertedCommit = await syncTrx.selectFrom('sync_commits')
219
+ .selectAll()
220
+ .where('partition_id', '=', partitionId)
221
+ .where('client_id', '=', request.clientId)
222
+ .where('client_commit_id', '=', request.clientCommitId)
223
+ .executeTakeFirstOrThrow();
224
+ const commitSeq = Number(insertedCommit.commit_seq);
225
+ const commitId = `${request.clientId}:${request.clientCommitId}`;
226
+ const savepointName = 'sync_apply';
227
+ const useSavepoints = dialect.supportsSavepoints;
228
+ let savepointCreated = false;
229
+ try {
230
+ // Apply the commit under a savepoint so we can roll back app writes on conflict
231
+ // while still persisting the commit-level cached response.
232
+ if (useSavepoints) {
233
+ await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
234
+ savepointCreated = true;
235
+ }
236
+ const allEmitted = [];
237
+ const results = [];
238
+ const affectedTablesSet = new Set();
239
+ for (let i = 0; i < ops.length; i++) {
240
+ const op = ops[i];
241
+ const handler = args.shapes.getOrThrow(op.table);
242
+ const applied = await handler.applyOperation({
243
+ db: trx,
244
+ trx,
245
+ actorId: args.actorId,
246
+ clientId: request.clientId,
247
+ commitId,
248
+ schemaVersion: request.schemaVersion,
249
+ }, op, i);
250
+ if (applied.result.status !== 'applied') {
251
+ results.push(applied.result);
252
+ throw new RejectCommitError({
253
+ ok: true,
254
+ status: 'rejected',
255
+ commitSeq,
256
+ results,
257
+ });
258
+ }
259
+ // Framework-level enforcement: emitted changes must have scopes
260
+ for (const c of applied.emittedChanges ?? []) {
261
+ const scopes = c?.scopes;
262
+ if (!scopes || typeof scopes !== 'object') {
263
+ results.push({
264
+ opIndex: i,
265
+ status: 'error',
266
+ error: 'MISSING_SCOPES',
267
+ code: 'INVALID_SCOPE',
268
+ retriable: false,
269
+ });
270
+ throw new RejectCommitError({
271
+ ok: true,
272
+ status: 'rejected',
273
+ commitSeq,
274
+ results,
275
+ });
276
+ }
277
+ }
278
+ results.push(applied.result);
279
+ allEmitted.push(...applied.emittedChanges);
280
+ for (const c of applied.emittedChanges) {
281
+ affectedTablesSet.add(c.table);
282
+ }
283
+ }
284
+ if (allEmitted.length > 0) {
285
+ const changeRows = allEmitted.map((c) => ({
286
+ partition_id: partitionId,
287
+ commit_seq: commitSeq,
288
+ table: c.table,
289
+ row_id: c.row_id,
290
+ op: c.op,
291
+ row_json: c.row_json,
292
+ row_version: c.row_version,
293
+ scopes: dialect.scopesToDb(c.scopes),
294
+ }));
295
+ await syncTrx
296
+ .insertInto('sync_changes')
297
+ .values(changeRows)
298
+ .execute();
299
+ }
300
+ const appliedResponse = {
203
301
  ok: true,
204
- status: 'rejected',
302
+ status: 'applied',
205
303
  commitSeq,
206
304
  results,
207
- });
208
- }
209
- // Framework-level enforcement: emitted changes must have scopes
210
- for (const c of applied.emittedChanges ?? []) {
211
- const scopes = c?.scopes;
212
- if (!scopes || typeof scopes !== 'object') {
213
- results.push({
214
- opIndex: i,
215
- status: 'error',
216
- error: 'MISSING_SCOPES',
217
- code: 'INVALID_SCOPE',
218
- retriable: false,
219
- });
220
- throw new RejectCommitError({
221
- ok: true,
222
- status: 'rejected',
223
- commitSeq,
224
- results,
225
- });
305
+ };
306
+ const affectedTables = Array.from(affectedTablesSet).sort();
307
+ const appliedCommitUpdate = {
308
+ result_json: appliedResponse,
309
+ change_count: allEmitted.length,
310
+ affected_tables: affectedTables,
311
+ };
312
+ await syncTrx
313
+ .updateTable('sync_commits')
314
+ .set(appliedCommitUpdate)
315
+ .where('commit_seq', '=', commitSeq)
316
+ .execute();
317
+ // Insert table commits for subscription filtering
318
+ if (affectedTables.length > 0) {
319
+ const tableCommits = affectedTables.map((table) => ({
320
+ partition_id: partitionId,
321
+ table,
322
+ commit_seq: commitSeq,
323
+ }));
324
+ await syncTrx
325
+ .insertInto('sync_table_commits')
326
+ .values(tableCommits)
327
+ .onConflict((oc) => oc
328
+ .columns(['partition_id', 'table', 'commit_seq'])
329
+ .doNothing())
330
+ .execute();
331
+ }
332
+ if (useSavepoints) {
333
+ await sql
334
+ .raw(`RELEASE SAVEPOINT ${savepointName}`)
335
+ .execute(trx);
226
336
  }
337
+ return {
338
+ response: appliedResponse,
339
+ affectedTables,
340
+ scopeKeys: scopeKeysFromEmitted(allEmitted),
341
+ emittedChanges: allEmitted.map((c) => ({
342
+ table: c.table,
343
+ row_id: c.row_id,
344
+ op: c.op,
345
+ row_json: c.row_json,
346
+ row_version: c.row_version,
347
+ scopes: c.scopes,
348
+ })),
349
+ };
227
350
  }
228
- results.push(applied.result);
229
- allEmitted.push(...applied.emittedChanges);
230
- for (const c of applied.emittedChanges) {
231
- affectedTablesSet.add(c.table);
351
+ catch (err) {
352
+ // Roll back app writes but keep the commit row.
353
+ if (savepointCreated) {
354
+ try {
355
+ await sql
356
+ .raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
357
+ .execute(trx);
358
+ await sql
359
+ .raw(`RELEASE SAVEPOINT ${savepointName}`)
360
+ .execute(trx);
361
+ }
362
+ catch (savepointErr) {
363
+ // If savepoint rollback fails, the transaction may be in an
364
+ // inconsistent state. Log and rethrow to fail the entire commit
365
+ // rather than risk data corruption.
366
+ console.error('[pushCommit] Savepoint rollback failed:', savepointErr);
367
+ throw savepointErr;
368
+ }
369
+ }
370
+ if (!(err instanceof RejectCommitError))
371
+ throw err;
372
+ const rejectedCommitUpdate = {
373
+ result_json: err.response,
374
+ change_count: 0,
375
+ affected_tables: [],
376
+ };
377
+ // Persist the rejected response for commit-level idempotency.
378
+ await syncTrx
379
+ .updateTable('sync_commits')
380
+ .set(rejectedCommitUpdate)
381
+ .where('commit_seq', '=', commitSeq)
382
+ .execute();
383
+ return {
384
+ response: err.response,
385
+ affectedTables: [],
386
+ scopeKeys: [],
387
+ emittedChanges: [],
388
+ };
232
389
  }
233
- }
234
- if (allEmitted.length > 0) {
235
- const changeRows = allEmitted.map((c) => ({
236
- partition_id: partitionId,
237
- commit_seq: commitSeq,
238
- table: c.table,
239
- row_id: c.row_id,
240
- op: c.op,
241
- row_json: c.row_json,
242
- row_version: c.row_version,
243
- scopes: dialect.scopesToDb(c.scopes),
244
- }));
245
- await syncTrx.insertInto('sync_changes').values(changeRows).execute();
246
- }
247
- const appliedResponse = {
248
- ok: true,
249
- status: 'applied',
250
- commitSeq,
251
- results,
252
- };
253
- const affectedTables = Array.from(affectedTablesSet).sort();
254
- const appliedCommitUpdate = {
255
- result_json: appliedResponse,
256
- change_count: allEmitted.length,
257
- affected_tables: affectedTables,
258
- };
259
- await syncTrx
260
- .updateTable('sync_commits')
261
- .set(appliedCommitUpdate)
262
- .where('commit_seq', '=', commitSeq)
263
- .execute();
264
- // Insert table commits for subscription filtering
265
- if (affectedTables.length > 0) {
266
- const tableCommits = affectedTables.map((table) => ({
267
- partition_id: partitionId,
268
- table,
269
- commit_seq: commitSeq,
270
- }));
271
- await syncTrx
272
- .insertInto('sync_table_commits')
273
- .values(tableCommits)
274
- .onConflict((oc) => oc.columns(['partition_id', 'table', 'commit_seq']).doNothing())
275
- .execute();
276
- }
277
- if (useSavepoints) {
278
- await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
279
- }
280
- return {
281
- response: appliedResponse,
282
- affectedTables,
283
- scopeKeys: scopeKeysFromEmitted(allEmitted),
284
- emittedChanges: allEmitted.map((c) => ({
285
- table: c.table,
286
- row_id: c.row_id,
287
- op: c.op,
288
- row_json: c.row_json,
289
- row_version: c.row_version,
290
- scopes: c.scopes,
291
- })),
292
- };
390
+ }));
293
391
  }
294
- catch (err) {
295
- // Roll back app writes but keep the commit row.
296
- if (savepointCreated) {
297
- try {
298
- await sql.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`).execute(trx);
299
- await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
300
- }
301
- catch (savepointErr) {
302
- // If savepoint rollback fails, the transaction may be in an
303
- // inconsistent state. Log and rethrow to fail the entire commit
304
- // rather than risk data corruption.
305
- console.error('[pushCommit] Savepoint rollback failed:', savepointErr);
306
- throw savepointErr;
307
- }
308
- }
309
- if (!(err instanceof RejectCommitError))
310
- throw err;
311
- const rejectedCommitUpdate = {
312
- result_json: err.response,
313
- change_count: 0,
314
- affected_tables: [],
315
- };
316
- // Persist the rejected response for commit-level idempotency.
317
- await syncTrx
318
- .updateTable('sync_commits')
319
- .set(rejectedCommitUpdate)
320
- .where('commit_seq', '=', commitSeq)
321
- .execute();
322
- return {
323
- response: err.response,
324
- affectedTables: [],
325
- scopeKeys: [],
326
- emittedChanges: [],
327
- };
392
+ catch (error) {
393
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
394
+ span.setAttribute('status', 'error');
395
+ span.setAttribute('duration_ms', durationMs);
396
+ span.setStatus('error');
397
+ recordPushMetrics({
398
+ status: 'error',
399
+ durationMs,
400
+ operationCount,
401
+ emittedChangeCount: 0,
402
+ affectedTableCount: 0,
403
+ });
404
+ captureSyncException(error, {
405
+ event: 'sync.server.push',
406
+ operationCount,
407
+ });
408
+ throw error;
328
409
  }
329
410
  });
330
411
  }