@syncular/server 0.0.6-185 → 0.0.6-201

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
@@ -75,16 +75,426 @@ function recordPushMetrics(args) {
75
75
  attributes: { status },
76
76
  });
77
77
  }
78
+ function createRejectedPushResult(error) {
79
+ return {
80
+ response: {
81
+ ok: true,
82
+ status: 'rejected',
83
+ results: [error],
84
+ },
85
+ affectedTables: [],
86
+ scopeKeys: [],
87
+ emittedChanges: [],
88
+ commitActorId: null,
89
+ commitCreatedAt: null,
90
+ };
91
+ }
92
+ function createRejectedPushResponse(error, commitSeq) {
93
+ return {
94
+ ok: true,
95
+ status: 'rejected',
96
+ ...(commitSeq !== undefined ? { commitSeq } : {}),
97
+ results: [error],
98
+ };
99
+ }
100
+ function validatePushRequest(request) {
101
+ if (!request.clientId || !request.clientCommitId) {
102
+ return {
103
+ opIndex: 0,
104
+ status: 'error',
105
+ error: 'INVALID_REQUEST',
106
+ code: 'INVALID_REQUEST',
107
+ retriable: false,
108
+ };
109
+ }
110
+ const ops = request.operations ?? [];
111
+ if (!Array.isArray(ops) || ops.length === 0) {
112
+ return {
113
+ opIndex: 0,
114
+ status: 'error',
115
+ error: 'EMPTY_COMMIT',
116
+ code: 'EMPTY_COMMIT',
117
+ retriable: false,
118
+ };
119
+ }
120
+ return null;
121
+ }
122
+ function shouldUseSavepoints(args) {
123
+ if (!args.dialect.supportsSavepoints) {
124
+ return false;
125
+ }
126
+ if ((args.operations?.length ?? 0) !== 1) {
127
+ return true;
128
+ }
129
+ const singleOp = args.operations?.[0];
130
+ if (!singleOp) {
131
+ return true;
132
+ }
133
+ const singleOpHandler = args.handlers.byTable.get(singleOp.table);
134
+ if (!singleOpHandler) {
135
+ throw new Error(`Unknown table: ${singleOp.table}`);
136
+ }
137
+ return !singleOpHandler.canRejectSingleOperationWithoutSavepoint;
138
+ }
139
+ async function persistEmittedChanges(args) {
140
+ if (args.emittedChanges.length === 0) {
141
+ return;
142
+ }
143
+ const syncTrx = args.trx;
144
+ const changeRows = args.emittedChanges.map((change) => ({
145
+ partition_id: args.partitionId,
146
+ commit_seq: args.commitSeq,
147
+ table: change.table,
148
+ row_id: change.row_id,
149
+ op: change.op,
150
+ row_json: toDialectJsonValue(args.dialect, change.row_json),
151
+ row_version: change.row_version,
152
+ scopes: args.dialect.scopesToDb(change.scopes),
153
+ }));
154
+ await syncTrx.insertInto('sync_changes').values(changeRows).execute();
155
+ }
156
+ async function persistCommitOutcome(args) {
157
+ const syncTrx = args.trx;
158
+ await syncTrx
159
+ .updateTable('sync_commits')
160
+ .set({
161
+ result_json: toDialectJsonValue(args.dialect, args.response),
162
+ change_count: args.emittedChangeCount,
163
+ affected_tables: args.dialect.arrayToDb(args.affectedTables),
164
+ })
165
+ .where('commit_seq', '=', args.commitSeq)
166
+ .execute();
167
+ if (args.affectedTables.length === 0) {
168
+ return;
169
+ }
170
+ await syncTrx
171
+ .insertInto('sync_table_commits')
172
+ .values(args.affectedTables.map((table) => ({
173
+ partition_id: args.partitionId,
174
+ table,
175
+ commit_seq: args.commitSeq,
176
+ })))
177
+ .onConflict((oc) => oc.columns(['partition_id', 'table', 'commit_seq']).doNothing())
178
+ .execute();
179
+ }
180
+ async function loadExistingCommitResult(args) {
181
+ let query = args.syncTrx.selectFrom('sync_commits')
182
+ .selectAll()
183
+ .where('partition_id', '=', args.partitionId)
184
+ .where('client_id', '=', args.request.clientId)
185
+ .where('client_commit_id', '=', args.request.clientCommitId);
186
+ if (args.dialect.supportsForUpdate) {
187
+ query = query.forUpdate();
188
+ }
189
+ const existing = await query.executeTakeFirstOrThrow();
190
+ const parsedCached = parseJsonValue(existing.result_json);
191
+ if (!isSyncPushResponse(parsedCached)) {
192
+ return createRejectedPushResult({
193
+ opIndex: 0,
194
+ status: 'error',
195
+ error: 'IDEMPOTENCY_CACHE_MISS',
196
+ code: 'INTERNAL',
197
+ retriable: true,
198
+ });
199
+ }
200
+ const base = {
201
+ ...parsedCached,
202
+ commitSeq: Number(existing.commit_seq),
203
+ };
204
+ if (parsedCached.status === 'applied') {
205
+ const tablesFromDb = args.dialect.dbToArray(existing.affected_tables);
206
+ return {
207
+ response: { ...base, status: 'cached' },
208
+ affectedTables: tablesFromDb.length > 0
209
+ ? tablesFromDb
210
+ : await readCommitAffectedTables(args.trx, args.dialect, Number(existing.commit_seq), args.partitionId),
211
+ scopeKeys: [],
212
+ emittedChanges: [],
213
+ commitActorId: typeof existing.actor_id === 'string' ? existing.actor_id : null,
214
+ commitCreatedAt: typeof existing.created_at === 'string' ? existing.created_at : null,
215
+ };
216
+ }
217
+ return {
218
+ response: base,
219
+ affectedTables: [],
220
+ scopeKeys: [],
221
+ emittedChanges: [],
222
+ commitActorId: typeof existing.actor_id === 'string' ? existing.actor_id : null,
223
+ commitCreatedAt: typeof existing.created_at === 'string' ? existing.created_at : null,
224
+ };
225
+ }
226
+ async function insertPendingCommit(args) {
227
+ if (args.dialect.supportsInsertReturning) {
228
+ const insertedCommit = await args.syncTrx
229
+ .insertInto('sync_commits')
230
+ .values(args.commitRow)
231
+ .onConflict((oc) => oc
232
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
233
+ .doNothing())
234
+ .returning(['commit_seq'])
235
+ .executeTakeFirst();
236
+ if (!insertedCommit) {
237
+ return null;
238
+ }
239
+ return coerceNumber(insertedCommit.commit_seq) ?? 0;
240
+ }
241
+ const insertResult = await args.syncTrx
242
+ .insertInto('sync_commits')
243
+ .values(args.commitRow)
244
+ .onConflict((oc) => oc.columns(['partition_id', 'client_id', 'client_commit_id']).doNothing())
245
+ .executeTakeFirstOrThrow();
246
+ const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
247
+ if (insertedRows === 0) {
248
+ return null;
249
+ }
250
+ return coerceNumber(insertResult.insertId) ?? 0;
251
+ }
252
+ async function applyCommitOperations(args) {
253
+ const ops = args.request.operations ?? [];
254
+ const allEmitted = [];
255
+ const results = [];
256
+ const affectedTablesSet = new Set();
257
+ for (let i = 0; i < ops.length;) {
258
+ const op = ops[i];
259
+ const handler = args.handlers.byTable.get(op.table);
260
+ if (!handler) {
261
+ throw new Error(`Unknown table: ${op.table}`);
262
+ }
263
+ const operationCtx = {
264
+ db: args.trx,
265
+ trx: args.trx,
266
+ actorId: args.actorId,
267
+ auth: args.auth,
268
+ clientId: args.request.clientId,
269
+ commitId: args.commitId,
270
+ schemaVersion: args.request.schemaVersion,
271
+ };
272
+ let transformedOp = op;
273
+ for (const plugin of args.pushPlugins) {
274
+ if (!plugin.beforeApplyOperation)
275
+ continue;
276
+ const nextOp = await plugin.beforeApplyOperation({
277
+ ctx: operationCtx,
278
+ tableHandler: handler,
279
+ op: transformedOp,
280
+ opIndex: i,
281
+ });
282
+ assertOperationIdentityUnchanged(plugin.name, op, nextOp);
283
+ transformedOp = nextOp;
284
+ }
285
+ let appliedBatch = null;
286
+ let consumed = 1;
287
+ if (args.pushPlugins.length === 0 && handler.applyOperationBatch) {
288
+ const batchInput = [];
289
+ for (let j = i; j < ops.length; j++) {
290
+ const nextOp = ops[j];
291
+ if (nextOp.table !== op.table)
292
+ break;
293
+ batchInput.push({ op: nextOp, opIndex: j });
294
+ }
295
+ if (batchInput.length > 1) {
296
+ appliedBatch = await handler.applyOperationBatch(operationCtx, batchInput);
297
+ consumed = Math.max(1, appliedBatch.length);
298
+ }
299
+ }
300
+ if (!appliedBatch) {
301
+ let appliedSingle = await handler.applyOperation(operationCtx, transformedOp, i);
302
+ for (const plugin of args.pushPlugins) {
303
+ if (!plugin.afterApplyOperation)
304
+ continue;
305
+ appliedSingle = await plugin.afterApplyOperation({
306
+ ctx: operationCtx,
307
+ tableHandler: handler,
308
+ op: transformedOp,
309
+ opIndex: i,
310
+ applied: appliedSingle,
311
+ });
312
+ }
313
+ appliedBatch = [appliedSingle];
314
+ }
315
+ if (appliedBatch.length === 0) {
316
+ throw new Error(`Handler "${op.table}" returned no results from applyOperationBatch`);
317
+ }
318
+ for (const applied of appliedBatch) {
319
+ if (applied.result.status !== 'applied') {
320
+ results.push(applied.result);
321
+ throw new RejectCommitError(createRejectedPushResponse(applied.result, args.commitSeq));
322
+ }
323
+ for (const change of applied.emittedChanges ?? []) {
324
+ const scopes = change?.scopes;
325
+ if (!scopes || typeof scopes !== 'object') {
326
+ const error = {
327
+ opIndex: applied.result.opIndex,
328
+ status: 'error',
329
+ error: 'MISSING_SCOPES',
330
+ code: 'INVALID_SCOPE',
331
+ retriable: false,
332
+ };
333
+ results.push(error);
334
+ throw new RejectCommitError(createRejectedPushResponse(error, args.commitSeq));
335
+ }
336
+ }
337
+ results.push(applied.result);
338
+ allEmitted.push(...applied.emittedChanges);
339
+ for (const change of applied.emittedChanges) {
340
+ affectedTablesSet.add(change.table);
341
+ }
342
+ }
343
+ i += consumed;
344
+ }
345
+ return {
346
+ results,
347
+ emittedChanges: allEmitted,
348
+ affectedTables: Array.from(affectedTablesSet).sort(),
349
+ };
350
+ }
351
+ async function executePushCommitInExecutor(args) {
352
+ const { trx, dialect, handlers, request, pushPlugins } = args;
353
+ const actorId = args.auth.actorId;
354
+ const partitionId = args.auth.partitionId ?? 'default';
355
+ const ops = request.operations ?? [];
356
+ const syncTrx = trx;
357
+ if (!dialect.supportsSavepoints) {
358
+ await syncTrx
359
+ .deleteFrom('sync_commits')
360
+ .where('partition_id', '=', partitionId)
361
+ .where('client_id', '=', request.clientId)
362
+ .where('client_commit_id', '=', request.clientCommitId)
363
+ .where('result_json', 'is', null)
364
+ .execute();
365
+ }
366
+ const commitCreatedAt = new Date().toISOString();
367
+ const commitRow = {
368
+ partition_id: partitionId,
369
+ actor_id: actorId,
370
+ client_id: request.clientId,
371
+ client_commit_id: request.clientCommitId,
372
+ created_at: commitCreatedAt,
373
+ meta: null,
374
+ result_json: null,
375
+ };
376
+ let commitSeq = (await insertPendingCommit({
377
+ syncTrx,
378
+ dialect,
379
+ commitRow,
380
+ })) ?? 0;
381
+ if (commitSeq === 0) {
382
+ return loadExistingCommitResult({
383
+ trx,
384
+ syncTrx,
385
+ dialect,
386
+ request,
387
+ partitionId,
388
+ });
389
+ }
390
+ if (commitSeq <= 0) {
391
+ const insertedCommitRow = await syncTrx.selectFrom('sync_commits')
392
+ .selectAll()
393
+ .where('partition_id', '=', partitionId)
394
+ .where('client_id', '=', request.clientId)
395
+ .where('client_commit_id', '=', request.clientCommitId)
396
+ .executeTakeFirstOrThrow();
397
+ commitSeq = Number(insertedCommitRow.commit_seq);
398
+ }
399
+ const commitId = `${request.clientId}:${request.clientCommitId}`;
400
+ const savepointName = `sync_apply_${commitSeq}`;
401
+ const useSavepoints = shouldUseSavepoints({
402
+ dialect,
403
+ handlers,
404
+ operations: ops,
405
+ });
406
+ let savepointCreated = false;
407
+ try {
408
+ if (useSavepoints) {
409
+ await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
410
+ savepointCreated = true;
411
+ }
412
+ const applied = await applyCommitOperations({
413
+ trx,
414
+ handlers,
415
+ pushPlugins,
416
+ auth: args.auth,
417
+ request,
418
+ actorId,
419
+ commitId,
420
+ commitSeq,
421
+ });
422
+ const appliedResponse = {
423
+ ok: true,
424
+ status: 'applied',
425
+ commitSeq,
426
+ results: applied.results,
427
+ };
428
+ await persistEmittedChanges({
429
+ trx,
430
+ dialect,
431
+ partitionId,
432
+ commitSeq,
433
+ emittedChanges: applied.emittedChanges,
434
+ });
435
+ await persistCommitOutcome({
436
+ trx,
437
+ dialect,
438
+ partitionId,
439
+ commitSeq,
440
+ response: appliedResponse,
441
+ affectedTables: applied.affectedTables,
442
+ emittedChangeCount: applied.emittedChanges.length,
443
+ });
444
+ if (useSavepoints) {
445
+ await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
446
+ }
447
+ return {
448
+ response: appliedResponse,
449
+ affectedTables: applied.affectedTables,
450
+ scopeKeys: scopeKeysFromEmitted(applied.emittedChanges),
451
+ emittedChanges: applied.emittedChanges,
452
+ commitActorId: actorId,
453
+ commitCreatedAt,
454
+ };
455
+ }
456
+ catch (error) {
457
+ if (savepointCreated) {
458
+ try {
459
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`).execute(trx);
460
+ await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
461
+ }
462
+ catch (savepointError) {
463
+ console.error('[pushCommit] Savepoint rollback failed:', savepointError);
464
+ throw savepointError;
465
+ }
466
+ }
467
+ if (!(error instanceof RejectCommitError)) {
468
+ throw error;
469
+ }
470
+ await persistCommitOutcome({
471
+ trx,
472
+ dialect,
473
+ partitionId,
474
+ commitSeq,
475
+ response: error.response,
476
+ affectedTables: [],
477
+ emittedChangeCount: 0,
478
+ });
479
+ return {
480
+ response: error.response,
481
+ affectedTables: [],
482
+ scopeKeys: [],
483
+ emittedChanges: [],
484
+ commitActorId: actorId,
485
+ commitCreatedAt,
486
+ };
487
+ }
488
+ }
78
489
  export async function pushCommit(args) {
79
490
  const { db, dialect, handlers, request } = args;
80
491
  const pushPlugins = sortServerPushPlugins(args.plugins);
81
- const actorId = args.auth.actorId;
82
- const partitionId = args.auth.partitionId ?? 'default';
83
492
  const requestedOps = Array.isArray(request.operations)
84
493
  ? request.operations
85
494
  : [];
86
495
  const operationCount = requestedOps.length;
87
496
  const startedAtMs = Date.now();
497
+ const suppressTelemetry = args.suppressTelemetry === true;
88
498
  return startSyncSpan({
89
499
  name: 'sync.server.push',
90
500
  op: 'sync.push',
@@ -100,461 +510,137 @@ export async function pushCommit(args) {
100
510
  span.setAttribute('emitted_change_count', result.emittedChanges.length);
101
511
  span.setAttribute('affected_table_count', result.affectedTables.length);
102
512
  span.setStatus('ok');
103
- recordPushMetrics({
104
- status,
105
- durationMs,
106
- operationCount,
107
- emittedChangeCount: result.emittedChanges.length,
108
- affectedTableCount: result.affectedTables.length,
109
- });
513
+ if (!suppressTelemetry) {
514
+ recordPushMetrics({
515
+ status,
516
+ durationMs,
517
+ operationCount,
518
+ emittedChangeCount: result.emittedChanges.length,
519
+ affectedTableCount: result.affectedTables.length,
520
+ });
521
+ }
110
522
  return result;
111
523
  };
112
524
  try {
113
- if (!request.clientId || !request.clientCommitId) {
114
- return finalizeResult({
115
- response: {
116
- ok: true,
117
- status: 'rejected',
118
- results: [
119
- {
120
- opIndex: 0,
121
- status: 'error',
122
- error: 'INVALID_REQUEST',
123
- code: 'INVALID_REQUEST',
124
- retriable: false,
125
- },
126
- ],
127
- },
128
- affectedTables: [],
129
- scopeKeys: [],
130
- emittedChanges: [],
131
- commitActorId: null,
132
- commitCreatedAt: null,
133
- });
525
+ const validationError = validatePushRequest(request);
526
+ if (validationError) {
527
+ return finalizeResult(createRejectedPushResult(validationError));
134
528
  }
135
- const ops = request.operations ?? [];
136
- if (!Array.isArray(ops) || ops.length === 0) {
137
- return finalizeResult({
138
- response: {
139
- ok: true,
140
- status: 'rejected',
141
- results: [
142
- {
143
- opIndex: 0,
144
- status: 'error',
145
- error: 'EMPTY_COMMIT',
146
- code: 'EMPTY_COMMIT',
147
- retriable: false,
148
- },
149
- ],
150
- },
151
- affectedTables: [],
152
- scopeKeys: [],
153
- emittedChanges: [],
154
- commitActorId: null,
155
- commitCreatedAt: null,
529
+ return finalizeResult(await dialect.executeInTransaction(db, async (trx) => executePushCommitInExecutor({
530
+ trx,
531
+ dialect,
532
+ handlers,
533
+ pushPlugins,
534
+ auth: args.auth,
535
+ request,
536
+ })));
537
+ }
538
+ catch (error) {
539
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
540
+ span.setAttribute('status', 'error');
541
+ span.setAttribute('duration_ms', durationMs);
542
+ span.setStatus('error');
543
+ if (!suppressTelemetry) {
544
+ recordPushMetrics({
545
+ status: 'error',
546
+ durationMs,
547
+ operationCount,
548
+ emittedChangeCount: 0,
549
+ affectedTableCount: 0,
550
+ });
551
+ captureSyncException(error, {
552
+ event: 'sync.server.push',
553
+ operationCount,
156
554
  });
157
555
  }
158
- return finalizeResult(await dialect.executeInTransaction(db, async (trx) => {
159
- const syncTrx = trx;
160
- // Clean up any stale commit row with null result_json.
161
- // This can happen when a previous push inserted the commit row but crashed
162
- // before writing the result (e.g., on D1 without transaction support).
163
- if (!dialect.supportsSavepoints) {
164
- await syncTrx
165
- .deleteFrom('sync_commits')
166
- .where('partition_id', '=', partitionId)
167
- .where('client_id', '=', request.clientId)
168
- .where('client_commit_id', '=', request.clientCommitId)
169
- .where('result_json', 'is', null)
170
- .execute();
171
- }
172
- // Insert commit row (idempotency key)
173
- const commitRow = {
174
- partition_id: partitionId,
175
- actor_id: actorId,
176
- client_id: request.clientId,
177
- client_commit_id: request.clientCommitId,
178
- meta: null,
179
- result_json: null,
180
- };
181
- const loadExistingCommit = async () => {
182
- // Existing commit: return cached response (applied or rejected)
183
- // Use forUpdate() for row locking on databases that support it
184
- let query = syncTrx.selectFrom('sync_commits')
185
- .selectAll()
186
- .where('partition_id', '=', partitionId)
187
- .where('client_id', '=', request.clientId)
188
- .where('client_commit_id', '=', request.clientCommitId);
189
- if (dialect.supportsForUpdate) {
190
- query = query.forUpdate();
191
- }
192
- const existing = await query.executeTakeFirstOrThrow();
193
- const parsedCached = parseJsonValue(existing.result_json);
194
- if (!isSyncPushResponse(parsedCached)) {
195
- return {
196
- response: {
197
- ok: true,
198
- status: 'rejected',
199
- results: [
200
- {
201
- opIndex: 0,
202
- status: 'error',
203
- error: 'IDEMPOTENCY_CACHE_MISS',
204
- code: 'INTERNAL',
205
- retriable: true,
206
- },
207
- ],
208
- },
209
- affectedTables: [],
210
- scopeKeys: [],
211
- emittedChanges: [],
212
- commitActorId: typeof existing.actor_id === 'string'
213
- ? existing.actor_id
214
- : null,
215
- commitCreatedAt: typeof existing.created_at === 'string'
216
- ? existing.created_at
217
- : null,
218
- };
219
- }
220
- const base = {
221
- ...parsedCached,
222
- commitSeq: Number(existing.commit_seq),
223
- };
224
- if (parsedCached.status === 'applied') {
225
- const tablesFromDb = dialect.dbToArray(existing.affected_tables);
226
- return {
227
- response: { ...base, status: 'cached' },
228
- affectedTables: tablesFromDb.length > 0
229
- ? tablesFromDb
230
- : await readCommitAffectedTables(trx, dialect, Number(existing.commit_seq), partitionId),
231
- scopeKeys: [],
232
- emittedChanges: [],
233
- commitActorId: typeof existing.actor_id === 'string'
234
- ? existing.actor_id
235
- : null,
236
- commitCreatedAt: typeof existing.created_at === 'string'
237
- ? existing.created_at
238
- : null,
239
- };
240
- }
241
- return {
242
- response: base,
243
- affectedTables: [],
244
- scopeKeys: [],
245
- emittedChanges: [],
246
- commitActorId: typeof existing.actor_id === 'string'
247
- ? existing.actor_id
248
- : null,
249
- commitCreatedAt: typeof existing.created_at === 'string'
250
- ? existing.created_at
251
- : null,
252
- };
253
- };
254
- const loadPersistedCommitMetadata = async (seq) => {
255
- const persisted = await syncTrx.selectFrom('sync_commits')
256
- .selectAll()
257
- .where('commit_seq', '=', seq)
258
- .where('partition_id', '=', partitionId)
259
- .executeTakeFirst();
260
- return {
261
- commitActorId: typeof persisted?.actor_id === 'string'
262
- ? persisted.actor_id
263
- : null,
264
- commitCreatedAt: typeof persisted?.created_at === 'string'
265
- ? persisted.created_at
266
- : null,
267
- };
268
- };
269
- let commitSeq = 0;
270
- if (dialect.supportsInsertReturning) {
271
- const insertedCommit = await syncTrx
272
- .insertInto('sync_commits')
273
- .values(commitRow)
274
- .onConflict((oc) => oc
275
- .columns(['partition_id', 'client_id', 'client_commit_id'])
276
- .doNothing())
277
- .returning(['commit_seq'])
278
- .executeTakeFirst();
279
- if (!insertedCommit) {
280
- return loadExistingCommit();
281
- }
282
- commitSeq = coerceNumber(insertedCommit.commit_seq) ?? 0;
283
- }
284
- else {
285
- const insertResult = await syncTrx
286
- .insertInto('sync_commits')
287
- .values(commitRow)
288
- .onConflict((oc) => oc
289
- .columns(['partition_id', 'client_id', 'client_commit_id'])
290
- .doNothing())
291
- .executeTakeFirstOrThrow();
292
- const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
293
- if (insertedRows === 0) {
294
- return loadExistingCommit();
295
- }
296
- commitSeq = coerceNumber(insertResult.insertId) ?? 0;
297
- }
298
- if (commitSeq <= 0) {
299
- const insertedCommitRow = await syncTrx.selectFrom('sync_commits')
300
- .selectAll()
301
- .where('partition_id', '=', partitionId)
302
- .where('client_id', '=', request.clientId)
303
- .where('client_commit_id', '=', request.clientCommitId)
304
- .executeTakeFirstOrThrow();
305
- commitSeq = Number(insertedCommitRow.commit_seq);
306
- }
307
- const commitId = `${request.clientId}:${request.clientCommitId}`;
308
- const savepointName = 'sync_apply';
309
- let useSavepoints = dialect.supportsSavepoints;
310
- if (useSavepoints && ops.length === 1) {
311
- const singleOpHandler = handlers.byTable.get(ops[0].table);
312
- if (!singleOpHandler) {
313
- throw new Error(`Unknown table: ${ops[0].table}`);
314
- }
315
- if (singleOpHandler.canRejectSingleOperationWithoutSavepoint) {
316
- useSavepoints = false;
317
- }
318
- }
319
- let savepointCreated = false;
320
- try {
321
- // Apply the commit under a savepoint so we can roll back app writes on conflict
322
- // while still persisting the commit-level cached response.
323
- if (useSavepoints) {
324
- await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
325
- savepointCreated = true;
326
- }
327
- const allEmitted = [];
328
- const results = [];
329
- const affectedTablesSet = new Set();
330
- for (let i = 0; i < ops.length;) {
331
- const op = ops[i];
332
- const handler = handlers.byTable.get(op.table);
333
- if (!handler) {
334
- throw new Error(`Unknown table: ${op.table}`);
335
- }
336
- const operationCtx = {
337
- db: trx,
338
- trx,
339
- actorId,
340
- auth: args.auth,
341
- clientId: request.clientId,
342
- commitId,
343
- schemaVersion: request.schemaVersion,
344
- };
345
- let transformedOp = op;
346
- for (const plugin of pushPlugins) {
347
- if (!plugin.beforeApplyOperation)
348
- continue;
349
- const nextOp = await plugin.beforeApplyOperation({
350
- ctx: operationCtx,
351
- tableHandler: handler,
352
- op: transformedOp,
353
- opIndex: i,
354
- });
355
- assertOperationIdentityUnchanged(plugin.name, op, nextOp);
356
- transformedOp = nextOp;
357
- }
358
- let appliedBatch = null;
359
- let consumed = 1;
360
- if (pushPlugins.length === 0 &&
361
- handler.applyOperationBatch &&
362
- dialect.supportsInsertReturning) {
363
- const batchInput = [];
364
- for (let j = i; j < ops.length; j++) {
365
- const nextOp = ops[j];
366
- if (nextOp.table !== op.table)
367
- break;
368
- batchInput.push({ op: nextOp, opIndex: j });
369
- }
370
- if (batchInput.length > 1) {
371
- appliedBatch = await handler.applyOperationBatch(operationCtx, batchInput);
372
- consumed = Math.max(1, appliedBatch.length);
373
- }
374
- }
375
- if (!appliedBatch) {
376
- let appliedSingle = await handler.applyOperation(operationCtx, transformedOp, i);
377
- for (const plugin of pushPlugins) {
378
- if (!plugin.afterApplyOperation)
379
- continue;
380
- appliedSingle = await plugin.afterApplyOperation({
381
- ctx: operationCtx,
382
- tableHandler: handler,
383
- op: transformedOp,
384
- opIndex: i,
385
- applied: appliedSingle,
386
- });
387
- }
388
- appliedBatch = [appliedSingle];
389
- }
390
- if (appliedBatch.length === 0) {
391
- throw new Error(`Handler "${op.table}" returned no results from applyOperationBatch`);
392
- }
393
- for (const applied of appliedBatch) {
394
- if (applied.result.status !== 'applied') {
395
- results.push(applied.result);
396
- throw new RejectCommitError({
397
- ok: true,
398
- status: 'rejected',
399
- commitSeq,
400
- results,
401
- });
402
- }
403
- // Framework-level enforcement: emitted changes must have scopes
404
- for (const c of applied.emittedChanges ?? []) {
405
- const scopes = c?.scopes;
406
- if (!scopes || typeof scopes !== 'object') {
407
- results.push({
408
- opIndex: applied.result.opIndex,
409
- status: 'error',
410
- error: 'MISSING_SCOPES',
411
- code: 'INVALID_SCOPE',
412
- retriable: false,
413
- });
414
- throw new RejectCommitError({
415
- ok: true,
416
- status: 'rejected',
417
- commitSeq,
418
- results,
419
- });
420
- }
421
- }
422
- results.push(applied.result);
423
- allEmitted.push(...applied.emittedChanges);
424
- for (const c of applied.emittedChanges) {
425
- affectedTablesSet.add(c.table);
426
- }
427
- }
428
- i += consumed;
429
- }
430
- if (allEmitted.length > 0) {
431
- const changeRows = allEmitted.map((c) => ({
432
- partition_id: partitionId,
433
- commit_seq: commitSeq,
434
- table: c.table,
435
- row_id: c.row_id,
436
- op: c.op,
437
- row_json: toDialectJsonValue(dialect, c.row_json),
438
- row_version: c.row_version,
439
- scopes: dialect.scopesToDb(c.scopes),
440
- }));
441
- await syncTrx
442
- .insertInto('sync_changes')
443
- .values(changeRows)
444
- .execute();
445
- }
446
- const appliedResponse = {
447
- ok: true,
448
- status: 'applied',
449
- commitSeq,
450
- results,
451
- };
452
- const affectedTables = Array.from(affectedTablesSet).sort();
453
- const appliedCommitUpdate = {
454
- result_json: toDialectJsonValue(dialect, appliedResponse),
455
- change_count: allEmitted.length,
456
- affected_tables: dialect.arrayToDb(affectedTables),
457
- };
458
- await syncTrx
459
- .updateTable('sync_commits')
460
- .set(appliedCommitUpdate)
461
- .where('commit_seq', '=', commitSeq)
462
- .execute();
463
- // Insert table commits for subscription filtering
464
- if (affectedTables.length > 0) {
465
- const tableCommits = affectedTables.map((table) => ({
466
- partition_id: partitionId,
467
- table,
468
- commit_seq: commitSeq,
469
- }));
470
- await syncTrx
471
- .insertInto('sync_table_commits')
472
- .values(tableCommits)
473
- .onConflict((oc) => oc
474
- .columns(['partition_id', 'table', 'commit_seq'])
475
- .doNothing())
476
- .execute();
477
- }
478
- if (useSavepoints) {
479
- await sql
480
- .raw(`RELEASE SAVEPOINT ${savepointName}`)
481
- .execute(trx);
482
- }
483
- const commitMetadata = await loadPersistedCommitMetadata(commitSeq);
484
- return {
485
- response: appliedResponse,
486
- affectedTables,
487
- scopeKeys: scopeKeysFromEmitted(allEmitted),
488
- emittedChanges: allEmitted.map((c) => ({
489
- table: c.table,
490
- row_id: c.row_id,
491
- op: c.op,
492
- row_json: c.row_json,
493
- row_version: c.row_version,
494
- scopes: c.scopes,
495
- })),
496
- ...commitMetadata,
497
- };
498
- }
499
- catch (err) {
500
- // Roll back app writes but keep the commit row.
501
- if (savepointCreated) {
502
- try {
503
- await sql
504
- .raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
505
- .execute(trx);
506
- await sql
507
- .raw(`RELEASE SAVEPOINT ${savepointName}`)
508
- .execute(trx);
509
- }
510
- catch (savepointErr) {
511
- // If savepoint rollback fails, the transaction may be in an
512
- // inconsistent state. Log and rethrow to fail the entire commit
513
- // rather than risk data corruption.
514
- console.error('[pushCommit] Savepoint rollback failed:', savepointErr);
515
- throw savepointErr;
516
- }
556
+ throw error;
557
+ }
558
+ });
559
+ }
560
+ export async function pushCommitBatch(args) {
561
+ const { db, dialect, handlers, requests } = args;
562
+ const pushPlugins = sortServerPushPlugins(args.plugins);
563
+ const startedAtMs = Date.now();
564
+ const suppressTelemetry = args.suppressTelemetry === true;
565
+ const totalOperationCount = requests.reduce((count, request) => {
566
+ const operations = Array.isArray(request.operations)
567
+ ? request.operations
568
+ : [];
569
+ return count + operations.length;
570
+ }, 0);
571
+ return startSyncSpan({
572
+ name: 'sync.server.push_batch',
573
+ op: 'sync.push.batch',
574
+ attributes: {
575
+ commit_count: requests.length,
576
+ operation_count: totalOperationCount,
577
+ },
578
+ }, async (span) => {
579
+ try {
580
+ const results = await dialect.executeInTransaction(db, async (trx) => {
581
+ const executed = [];
582
+ for (const request of requests) {
583
+ const validationError = validatePushRequest(request);
584
+ if (validationError) {
585
+ executed.push(createRejectedPushResult(validationError));
586
+ continue;
517
587
  }
518
- if (!(err instanceof RejectCommitError))
519
- throw err;
520
- const rejectedCommitUpdate = {
521
- result_json: toDialectJsonValue(dialect, err.response),
522
- change_count: 0,
523
- affected_tables: dialect.arrayToDb([]),
524
- };
525
- // Persist the rejected response for commit-level idempotency.
526
- await syncTrx
527
- .updateTable('sync_commits')
528
- .set(rejectedCommitUpdate)
529
- .where('commit_seq', '=', commitSeq)
530
- .execute();
531
- const commitMetadata = await loadPersistedCommitMetadata(commitSeq);
532
- return {
533
- response: err.response,
534
- affectedTables: [],
535
- scopeKeys: [],
536
- emittedChanges: [],
537
- ...commitMetadata,
538
- };
588
+ executed.push(await executePushCommitInExecutor({
589
+ trx,
590
+ dialect,
591
+ handlers,
592
+ pushPlugins,
593
+ auth: args.auth,
594
+ request,
595
+ }));
539
596
  }
540
- }));
597
+ return executed;
598
+ });
599
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
600
+ const emittedChangeCount = results.reduce((count, result) => count + result.emittedChanges.length, 0);
601
+ const affectedTableCount = results.reduce((count, result) => count + result.affectedTables.length, 0);
602
+ const status = results.every((result) => result.response.status === 'cached')
603
+ ? 'cached'
604
+ : results.every((result) => result.response.status === 'applied' ||
605
+ result.response.status === 'cached')
606
+ ? 'applied'
607
+ : 'rejected';
608
+ span.setAttribute('status', status);
609
+ span.setAttribute('duration_ms', durationMs);
610
+ span.setAttribute('commit_count', results.length);
611
+ span.setAttribute('emitted_change_count', emittedChangeCount);
612
+ span.setAttribute('affected_table_count', affectedTableCount);
613
+ span.setStatus('ok');
614
+ if (!suppressTelemetry) {
615
+ recordPushMetrics({
616
+ status,
617
+ durationMs,
618
+ operationCount: totalOperationCount,
619
+ emittedChangeCount,
620
+ affectedTableCount,
621
+ });
622
+ }
623
+ return results;
541
624
  }
542
625
  catch (error) {
543
626
  const durationMs = Math.max(0, Date.now() - startedAtMs);
544
627
  span.setAttribute('status', 'error');
545
628
  span.setAttribute('duration_ms', durationMs);
546
629
  span.setStatus('error');
547
- recordPushMetrics({
548
- status: 'error',
549
- durationMs,
550
- operationCount,
551
- emittedChangeCount: 0,
552
- affectedTableCount: 0,
553
- });
554
- captureSyncException(error, {
555
- event: 'sync.server.push',
556
- operationCount,
557
- });
630
+ if (!suppressTelemetry) {
631
+ recordPushMetrics({
632
+ status: 'error',
633
+ durationMs,
634
+ operationCount: totalOperationCount,
635
+ emittedChangeCount: 0,
636
+ affectedTableCount: 0,
637
+ });
638
+ captureSyncException(error, {
639
+ event: 'sync.server.push_batch',
640
+ commitCount: requests.length,
641
+ operationCount: totalOperationCount,
642
+ });
643
+ }
558
644
  throw error;
559
645
  }
560
646
  });