@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/src/push.ts CHANGED
@@ -7,20 +7,14 @@ import {
7
7
  type SyncPushResponse,
8
8
  startSyncSpan,
9
9
  } from '@syncular/core';
10
- import type {
11
- Insertable,
12
- Kysely,
13
- SelectQueryBuilder,
14
- SqlBool,
15
- Updateable,
16
- } from 'kysely';
10
+ import type { Insertable, Kysely, SelectQueryBuilder, SqlBool } from 'kysely';
17
11
  import { sql } from 'kysely';
18
12
  import {
19
13
  coerceNumber,
20
14
  parseJsonValue,
21
15
  toDialectJsonValue,
22
16
  } from './dialect/helpers';
23
- import type { ServerSyncDialect } from './dialect/types';
17
+ import type { DbExecutor, ServerSyncDialect } from './dialect/types';
24
18
  import type { ServerHandlerCollection } from './handlers/collection';
25
19
  import type { SyncServerAuth } from './handlers/types';
26
20
  import {
@@ -31,6 +25,10 @@ import type { SyncCoreDb } from './schema';
31
25
 
32
26
  // biome-ignore lint/complexity/noBannedTypes: Kysely uses `{}` as the initial "no selected columns yet" marker.
33
27
  type EmptySelection = {};
28
+ type SyncMetadataTrx = Pick<
29
+ Kysely<SyncCoreDb>,
30
+ 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
31
+ >;
34
32
 
35
33
  export interface PushCommitResult {
36
34
  response: SyncPushResponse;
@@ -102,7 +100,7 @@ function assertOperationIdentityUnchanged(
102
100
  }
103
101
 
104
102
  async function readCommitAffectedTables<DB extends SyncCoreDb>(
105
- db: Kysely<DB>,
103
+ db: DbExecutor<DB>,
106
104
  dialect: ServerSyncDialect,
107
105
  commitSeq: number,
108
106
  partitionId: string
@@ -185,6 +183,588 @@ function recordPushMetrics(args: {
185
183
  );
186
184
  }
187
185
 
186
+ function createRejectedPushResult(
187
+ error: SyncPushResponse['results'][number]
188
+ ): PushCommitResult {
189
+ return {
190
+ response: {
191
+ ok: true,
192
+ status: 'rejected',
193
+ results: [error],
194
+ },
195
+ affectedTables: [],
196
+ scopeKeys: [],
197
+ emittedChanges: [],
198
+ commitActorId: null,
199
+ commitCreatedAt: null,
200
+ };
201
+ }
202
+
203
+ function createRejectedPushResponse(
204
+ error: SyncPushResponse['results'][number],
205
+ commitSeq?: number
206
+ ): SyncPushResponse {
207
+ return {
208
+ ok: true,
209
+ status: 'rejected',
210
+ ...(commitSeq !== undefined ? { commitSeq } : {}),
211
+ results: [error],
212
+ };
213
+ }
214
+
215
+ function validatePushRequest(
216
+ request: SyncPushRequest
217
+ ): SyncPushResponse['results'][number] | null {
218
+ if (!request.clientId || !request.clientCommitId) {
219
+ return {
220
+ opIndex: 0,
221
+ status: 'error',
222
+ error: 'INVALID_REQUEST',
223
+ code: 'INVALID_REQUEST',
224
+ retriable: false,
225
+ };
226
+ }
227
+
228
+ const ops = request.operations ?? [];
229
+ if (!Array.isArray(ops) || ops.length === 0) {
230
+ return {
231
+ opIndex: 0,
232
+ status: 'error',
233
+ error: 'EMPTY_COMMIT',
234
+ code: 'EMPTY_COMMIT',
235
+ retriable: false,
236
+ };
237
+ }
238
+
239
+ return null;
240
+ }
241
+
242
+ function shouldUseSavepoints<
243
+ DB extends SyncCoreDb,
244
+ Auth extends SyncServerAuth,
245
+ >(args: {
246
+ dialect: ServerSyncDialect;
247
+ handlers: ServerHandlerCollection<DB, Auth>;
248
+ operations: SyncPushRequest['operations'];
249
+ }): boolean {
250
+ if (!args.dialect.supportsSavepoints) {
251
+ return false;
252
+ }
253
+
254
+ if ((args.operations?.length ?? 0) !== 1) {
255
+ return true;
256
+ }
257
+
258
+ const singleOp = args.operations?.[0];
259
+ if (!singleOp) {
260
+ return true;
261
+ }
262
+
263
+ const singleOpHandler = args.handlers.byTable.get(singleOp.table);
264
+ if (!singleOpHandler) {
265
+ throw new Error(`Unknown table: ${singleOp.table}`);
266
+ }
267
+
268
+ return !singleOpHandler.canRejectSingleOperationWithoutSavepoint;
269
+ }
270
+
271
+ async function persistEmittedChanges<DB extends SyncCoreDb>(args: {
272
+ trx: DbExecutor<DB>;
273
+ dialect: ServerSyncDialect;
274
+ partitionId: string;
275
+ commitSeq: number;
276
+ emittedChanges: PushCommitResult['emittedChanges'];
277
+ }): Promise<void> {
278
+ if (args.emittedChanges.length === 0) {
279
+ return;
280
+ }
281
+
282
+ const syncTrx = args.trx as Pick<Kysely<SyncCoreDb>, 'insertInto'>;
283
+ const changeRows: Array<Insertable<SyncCoreDb['sync_changes']>> =
284
+ args.emittedChanges.map((change) => ({
285
+ partition_id: args.partitionId,
286
+ commit_seq: args.commitSeq,
287
+ table: change.table,
288
+ row_id: change.row_id,
289
+ op: change.op,
290
+ row_json: toDialectJsonValue(args.dialect, change.row_json),
291
+ row_version: change.row_version,
292
+ scopes: args.dialect.scopesToDb(change.scopes),
293
+ }));
294
+
295
+ await syncTrx.insertInto('sync_changes').values(changeRows).execute();
296
+ }
297
+
298
+ async function persistCommitOutcome<DB extends SyncCoreDb>(args: {
299
+ trx: DbExecutor<DB>;
300
+ dialect: ServerSyncDialect;
301
+ partitionId: string;
302
+ commitSeq: number;
303
+ response: SyncPushResponse;
304
+ affectedTables: string[];
305
+ emittedChangeCount: number;
306
+ }): Promise<void> {
307
+ const syncTrx = args.trx as Pick<
308
+ Kysely<SyncCoreDb>,
309
+ 'insertInto' | 'updateTable'
310
+ >;
311
+
312
+ await syncTrx
313
+ .updateTable('sync_commits')
314
+ .set({
315
+ result_json: toDialectJsonValue(args.dialect, args.response),
316
+ change_count: args.emittedChangeCount,
317
+ affected_tables: args.dialect.arrayToDb(args.affectedTables) as string[],
318
+ })
319
+ .where('commit_seq', '=', args.commitSeq)
320
+ .execute();
321
+
322
+ if (args.affectedTables.length === 0) {
323
+ return;
324
+ }
325
+
326
+ await syncTrx
327
+ .insertInto('sync_table_commits')
328
+ .values(
329
+ args.affectedTables.map((table) => ({
330
+ partition_id: args.partitionId,
331
+ table,
332
+ commit_seq: args.commitSeq,
333
+ }))
334
+ )
335
+ .onConflict((oc) =>
336
+ oc.columns(['partition_id', 'table', 'commit_seq']).doNothing()
337
+ )
338
+ .execute();
339
+ }
340
+
341
+ async function loadExistingCommitResult<DB extends SyncCoreDb>(args: {
342
+ trx: DbExecutor<DB>;
343
+ syncTrx: SyncMetadataTrx;
344
+ dialect: ServerSyncDialect;
345
+ request: SyncPushRequest;
346
+ partitionId: string;
347
+ }): Promise<PushCommitResult> {
348
+ let query = (
349
+ args.syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
350
+ SyncCoreDb,
351
+ 'sync_commits',
352
+ EmptySelection
353
+ >
354
+ )
355
+ .selectAll()
356
+ .where('partition_id', '=', args.partitionId)
357
+ .where('client_id', '=', args.request.clientId)
358
+ .where('client_commit_id', '=', args.request.clientCommitId);
359
+
360
+ if (args.dialect.supportsForUpdate) {
361
+ query = query.forUpdate();
362
+ }
363
+
364
+ const existing = await query.executeTakeFirstOrThrow();
365
+ const parsedCached = parseJsonValue(existing.result_json);
366
+
367
+ if (!isSyncPushResponse(parsedCached)) {
368
+ return createRejectedPushResult({
369
+ opIndex: 0,
370
+ status: 'error',
371
+ error: 'IDEMPOTENCY_CACHE_MISS',
372
+ code: 'INTERNAL',
373
+ retriable: true,
374
+ });
375
+ }
376
+
377
+ const base: SyncPushResponse = {
378
+ ...parsedCached,
379
+ commitSeq: Number(existing.commit_seq),
380
+ };
381
+
382
+ if (parsedCached.status === 'applied') {
383
+ const tablesFromDb = args.dialect.dbToArray(existing.affected_tables);
384
+ return {
385
+ response: { ...base, status: 'cached' },
386
+ affectedTables:
387
+ tablesFromDb.length > 0
388
+ ? tablesFromDb
389
+ : await readCommitAffectedTables(
390
+ args.trx,
391
+ args.dialect,
392
+ Number(existing.commit_seq),
393
+ args.partitionId
394
+ ),
395
+ scopeKeys: [],
396
+ emittedChanges: [],
397
+ commitActorId:
398
+ typeof existing.actor_id === 'string' ? existing.actor_id : null,
399
+ commitCreatedAt:
400
+ typeof existing.created_at === 'string' ? existing.created_at : null,
401
+ };
402
+ }
403
+
404
+ return {
405
+ response: base,
406
+ affectedTables: [],
407
+ scopeKeys: [],
408
+ emittedChanges: [],
409
+ commitActorId:
410
+ typeof existing.actor_id === 'string' ? existing.actor_id : null,
411
+ commitCreatedAt:
412
+ typeof existing.created_at === 'string' ? existing.created_at : null,
413
+ };
414
+ }
415
+
416
+ async function insertPendingCommit(args: {
417
+ syncTrx: SyncMetadataTrx;
418
+ dialect: ServerSyncDialect;
419
+ commitRow: Insertable<SyncCoreDb['sync_commits']>;
420
+ }): Promise<number | null> {
421
+ if (args.dialect.supportsInsertReturning) {
422
+ const insertedCommit = await args.syncTrx
423
+ .insertInto('sync_commits')
424
+ .values(args.commitRow)
425
+ .onConflict((oc) =>
426
+ oc
427
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
428
+ .doNothing()
429
+ )
430
+ .returning(['commit_seq'])
431
+ .executeTakeFirst();
432
+
433
+ if (!insertedCommit) {
434
+ return null;
435
+ }
436
+
437
+ return coerceNumber(insertedCommit.commit_seq) ?? 0;
438
+ }
439
+
440
+ const insertResult = await args.syncTrx
441
+ .insertInto('sync_commits')
442
+ .values(args.commitRow)
443
+ .onConflict((oc) =>
444
+ oc.columns(['partition_id', 'client_id', 'client_commit_id']).doNothing()
445
+ )
446
+ .executeTakeFirstOrThrow();
447
+
448
+ const insertedRows = Number(insertResult.numInsertedOrUpdatedRows ?? 0);
449
+ if (insertedRows === 0) {
450
+ return null;
451
+ }
452
+
453
+ return coerceNumber(insertResult.insertId) ?? 0;
454
+ }
455
+
456
+ async function applyCommitOperations<
457
+ DB extends SyncCoreDb,
458
+ Auth extends SyncServerAuth,
459
+ >(args: {
460
+ trx: DbExecutor<DB>;
461
+ handlers: ServerHandlerCollection<DB, Auth>;
462
+ pushPlugins: readonly SyncServerPushPlugin<DB, Auth>[];
463
+ auth: Auth;
464
+ request: SyncPushRequest;
465
+ actorId: string;
466
+ commitId: string;
467
+ commitSeq: number;
468
+ }): Promise<{
469
+ results: SyncPushResponse['results'];
470
+ emittedChanges: PushCommitResult['emittedChanges'];
471
+ affectedTables: string[];
472
+ }> {
473
+ const ops = args.request.operations ?? [];
474
+ const allEmitted: PushCommitResult['emittedChanges'] = [];
475
+ const results: SyncPushResponse['results'] = [];
476
+ const affectedTablesSet = new Set<string>();
477
+
478
+ for (let i = 0; i < ops.length; ) {
479
+ const op = ops[i]!;
480
+ const handler = args.handlers.byTable.get(op.table);
481
+ if (!handler) {
482
+ throw new Error(`Unknown table: ${op.table}`);
483
+ }
484
+
485
+ const operationCtx = {
486
+ db: args.trx,
487
+ trx: args.trx,
488
+ actorId: args.actorId,
489
+ auth: args.auth,
490
+ clientId: args.request.clientId,
491
+ commitId: args.commitId,
492
+ schemaVersion: args.request.schemaVersion,
493
+ };
494
+
495
+ let transformedOp = op;
496
+ for (const plugin of args.pushPlugins) {
497
+ if (!plugin.beforeApplyOperation) continue;
498
+ const nextOp = await plugin.beforeApplyOperation({
499
+ ctx: operationCtx,
500
+ tableHandler: handler,
501
+ op: transformedOp,
502
+ opIndex: i,
503
+ });
504
+ assertOperationIdentityUnchanged(plugin.name, op, nextOp);
505
+ transformedOp = nextOp;
506
+ }
507
+
508
+ let appliedBatch:
509
+ | Awaited<ReturnType<typeof handler.applyOperation>>[]
510
+ | null = null;
511
+ let consumed = 1;
512
+
513
+ if (args.pushPlugins.length === 0 && handler.applyOperationBatch) {
514
+ const batchInput = [];
515
+ for (let j = i; j < ops.length; j++) {
516
+ const nextOp = ops[j]!;
517
+ if (nextOp.table !== op.table) break;
518
+ batchInput.push({ op: nextOp, opIndex: j });
519
+ }
520
+
521
+ if (batchInput.length > 1) {
522
+ appliedBatch = await handler.applyOperationBatch(
523
+ operationCtx,
524
+ batchInput
525
+ );
526
+ consumed = Math.max(1, appliedBatch.length);
527
+ }
528
+ }
529
+
530
+ if (!appliedBatch) {
531
+ let appliedSingle = await handler.applyOperation(
532
+ operationCtx,
533
+ transformedOp,
534
+ i
535
+ );
536
+
537
+ for (const plugin of args.pushPlugins) {
538
+ if (!plugin.afterApplyOperation) continue;
539
+ appliedSingle = await plugin.afterApplyOperation({
540
+ ctx: operationCtx,
541
+ tableHandler: handler,
542
+ op: transformedOp,
543
+ opIndex: i,
544
+ applied: appliedSingle,
545
+ });
546
+ }
547
+
548
+ appliedBatch = [appliedSingle];
549
+ }
550
+
551
+ if (appliedBatch.length === 0) {
552
+ throw new Error(
553
+ `Handler "${op.table}" returned no results from applyOperationBatch`
554
+ );
555
+ }
556
+
557
+ for (const applied of appliedBatch) {
558
+ if (applied.result.status !== 'applied') {
559
+ results.push(applied.result);
560
+ throw new RejectCommitError(
561
+ createRejectedPushResponse(applied.result, args.commitSeq)
562
+ );
563
+ }
564
+
565
+ for (const change of applied.emittedChanges ?? []) {
566
+ const scopes = change?.scopes;
567
+ if (!scopes || typeof scopes !== 'object') {
568
+ const error: SyncPushResponse['results'][number] = {
569
+ opIndex: applied.result.opIndex,
570
+ status: 'error',
571
+ error: 'MISSING_SCOPES',
572
+ code: 'INVALID_SCOPE',
573
+ retriable: false,
574
+ };
575
+ results.push(error);
576
+ throw new RejectCommitError(
577
+ createRejectedPushResponse(error, args.commitSeq)
578
+ );
579
+ }
580
+ }
581
+
582
+ results.push(applied.result);
583
+ allEmitted.push(...applied.emittedChanges);
584
+ for (const change of applied.emittedChanges) {
585
+ affectedTablesSet.add(change.table);
586
+ }
587
+ }
588
+
589
+ i += consumed;
590
+ }
591
+
592
+ return {
593
+ results,
594
+ emittedChanges: allEmitted,
595
+ affectedTables: Array.from(affectedTablesSet).sort(),
596
+ };
597
+ }
598
+
599
+ async function executePushCommitInExecutor<
600
+ DB extends SyncCoreDb,
601
+ Auth extends SyncServerAuth,
602
+ >(args: {
603
+ trx: DbExecutor<DB>;
604
+ dialect: ServerSyncDialect;
605
+ handlers: ServerHandlerCollection<DB, Auth>;
606
+ pushPlugins: readonly SyncServerPushPlugin<DB, Auth>[];
607
+ auth: Auth;
608
+ request: SyncPushRequest;
609
+ }): Promise<PushCommitResult> {
610
+ const { trx, dialect, handlers, request, pushPlugins } = args;
611
+ const actorId = args.auth.actorId;
612
+ const partitionId = args.auth.partitionId ?? 'default';
613
+ const ops = request.operations ?? [];
614
+ const syncTrx = trx as SyncMetadataTrx;
615
+
616
+ if (!dialect.supportsSavepoints) {
617
+ await syncTrx
618
+ .deleteFrom('sync_commits')
619
+ .where('partition_id', '=', partitionId)
620
+ .where('client_id', '=', request.clientId)
621
+ .where('client_commit_id', '=', request.clientCommitId)
622
+ .where('result_json', 'is', null)
623
+ .execute();
624
+ }
625
+
626
+ const commitCreatedAt = new Date().toISOString();
627
+ const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
628
+ partition_id: partitionId,
629
+ actor_id: actorId,
630
+ client_id: request.clientId,
631
+ client_commit_id: request.clientCommitId,
632
+ created_at: commitCreatedAt,
633
+ meta: null,
634
+ result_json: null,
635
+ };
636
+ let commitSeq =
637
+ (await insertPendingCommit({
638
+ syncTrx,
639
+ dialect,
640
+ commitRow,
641
+ })) ?? 0;
642
+ if (commitSeq === 0) {
643
+ return loadExistingCommitResult({
644
+ trx,
645
+ syncTrx,
646
+ dialect,
647
+ request,
648
+ partitionId,
649
+ });
650
+ }
651
+
652
+ if (commitSeq <= 0) {
653
+ const insertedCommitRow = await (
654
+ syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
655
+ SyncCoreDb,
656
+ 'sync_commits',
657
+ EmptySelection
658
+ >
659
+ )
660
+ .selectAll()
661
+ .where('partition_id', '=', partitionId)
662
+ .where('client_id', '=', request.clientId)
663
+ .where('client_commit_id', '=', request.clientCommitId)
664
+ .executeTakeFirstOrThrow();
665
+ commitSeq = Number(insertedCommitRow.commit_seq);
666
+ }
667
+
668
+ const commitId = `${request.clientId}:${request.clientCommitId}`;
669
+ const savepointName = `sync_apply_${commitSeq}`;
670
+ const useSavepoints = shouldUseSavepoints({
671
+ dialect,
672
+ handlers,
673
+ operations: ops,
674
+ });
675
+ let savepointCreated = false;
676
+
677
+ try {
678
+ if (useSavepoints) {
679
+ await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
680
+ savepointCreated = true;
681
+ }
682
+
683
+ const applied = await applyCommitOperations({
684
+ trx,
685
+ handlers,
686
+ pushPlugins,
687
+ auth: args.auth,
688
+ request,
689
+ actorId,
690
+ commitId,
691
+ commitSeq,
692
+ });
693
+
694
+ const appliedResponse: SyncPushResponse = {
695
+ ok: true,
696
+ status: 'applied',
697
+ commitSeq,
698
+ results: applied.results,
699
+ };
700
+ await persistEmittedChanges({
701
+ trx,
702
+ dialect,
703
+ partitionId,
704
+ commitSeq,
705
+ emittedChanges: applied.emittedChanges,
706
+ });
707
+ await persistCommitOutcome({
708
+ trx,
709
+ dialect,
710
+ partitionId,
711
+ commitSeq,
712
+ response: appliedResponse,
713
+ affectedTables: applied.affectedTables,
714
+ emittedChangeCount: applied.emittedChanges.length,
715
+ });
716
+
717
+ if (useSavepoints) {
718
+ await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
719
+ }
720
+
721
+ return {
722
+ response: appliedResponse,
723
+ affectedTables: applied.affectedTables,
724
+ scopeKeys: scopeKeysFromEmitted(applied.emittedChanges),
725
+ emittedChanges: applied.emittedChanges,
726
+ commitActorId: actorId,
727
+ commitCreatedAt,
728
+ };
729
+ } catch (error) {
730
+ if (savepointCreated) {
731
+ try {
732
+ await sql.raw(`ROLLBACK TO SAVEPOINT ${savepointName}`).execute(trx);
733
+ await sql.raw(`RELEASE SAVEPOINT ${savepointName}`).execute(trx);
734
+ } catch (savepointError) {
735
+ console.error(
736
+ '[pushCommit] Savepoint rollback failed:',
737
+ savepointError
738
+ );
739
+ throw savepointError;
740
+ }
741
+ }
742
+
743
+ if (!(error instanceof RejectCommitError)) {
744
+ throw error;
745
+ }
746
+
747
+ await persistCommitOutcome({
748
+ trx,
749
+ dialect,
750
+ partitionId,
751
+ commitSeq,
752
+ response: error.response,
753
+ affectedTables: [],
754
+ emittedChangeCount: 0,
755
+ });
756
+
757
+ return {
758
+ response: error.response,
759
+ affectedTables: [],
760
+ scopeKeys: [],
761
+ emittedChanges: [],
762
+ commitActorId: actorId,
763
+ commitCreatedAt,
764
+ };
765
+ }
766
+ }
767
+
188
768
  export async function pushCommit<
189
769
  DB extends SyncCoreDb,
190
770
  Auth extends SyncServerAuth,
@@ -195,16 +775,16 @@ export async function pushCommit<
195
775
  plugins?: readonly SyncServerPushPlugin<DB, Auth>[];
196
776
  auth: Auth;
197
777
  request: SyncPushRequest;
778
+ suppressTelemetry?: boolean;
198
779
  }): Promise<PushCommitResult> {
199
780
  const { db, dialect, handlers, request } = args;
200
781
  const pushPlugins = sortServerPushPlugins(args.plugins);
201
- const actorId = args.auth.actorId;
202
- const partitionId = args.auth.partitionId ?? 'default';
203
782
  const requestedOps = Array.isArray(request.operations)
204
783
  ? request.operations
205
784
  : [];
206
785
  const operationCount = requestedOps.length;
207
786
  const startedAtMs = Date.now();
787
+ const suppressTelemetry = args.suppressTelemetry === true;
208
788
 
209
789
  return startSyncSpan(
210
790
  {
@@ -225,587 +805,178 @@ export async function pushCommit<
225
805
  span.setAttribute('affected_table_count', result.affectedTables.length);
226
806
  span.setStatus('ok');
227
807
 
228
- recordPushMetrics({
229
- status,
230
- durationMs,
231
- operationCount,
232
- emittedChangeCount: result.emittedChanges.length,
233
- affectedTableCount: result.affectedTables.length,
234
- });
808
+ if (!suppressTelemetry) {
809
+ recordPushMetrics({
810
+ status,
811
+ durationMs,
812
+ operationCount,
813
+ emittedChangeCount: result.emittedChanges.length,
814
+ affectedTableCount: result.affectedTables.length,
815
+ });
816
+ }
235
817
 
236
818
  return result;
237
819
  };
238
820
 
239
821
  try {
240
- if (!request.clientId || !request.clientCommitId) {
241
- return finalizeResult({
242
- response: {
243
- ok: true,
244
- status: 'rejected',
245
- results: [
246
- {
247
- opIndex: 0,
248
- status: 'error',
249
- error: 'INVALID_REQUEST',
250
- code: 'INVALID_REQUEST',
251
- retriable: false,
252
- },
253
- ],
254
- },
255
- affectedTables: [],
256
- scopeKeys: [],
257
- emittedChanges: [],
258
- commitActorId: null,
259
- commitCreatedAt: null,
260
- });
822
+ const validationError = validatePushRequest(request);
823
+ if (validationError) {
824
+ return finalizeResult(createRejectedPushResult(validationError));
261
825
  }
262
826
 
263
- const ops = request.operations ?? [];
264
- if (!Array.isArray(ops) || ops.length === 0) {
265
- return finalizeResult({
266
- response: {
267
- ok: true,
268
- status: 'rejected',
269
- results: [
270
- {
271
- opIndex: 0,
272
- status: 'error',
273
- error: 'EMPTY_COMMIT',
274
- code: 'EMPTY_COMMIT',
275
- retriable: false,
276
- },
277
- ],
278
- },
279
- affectedTables: [],
280
- scopeKeys: [],
281
- emittedChanges: [],
282
- commitActorId: null,
283
- commitCreatedAt: null,
827
+ return finalizeResult(
828
+ await dialect.executeInTransaction(db, async (trx) =>
829
+ executePushCommitInExecutor({
830
+ trx,
831
+ dialect,
832
+ handlers,
833
+ pushPlugins,
834
+ auth: args.auth,
835
+ request,
836
+ })
837
+ )
838
+ );
839
+ } catch (error) {
840
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
841
+ span.setAttribute('status', 'error');
842
+ span.setAttribute('duration_ms', durationMs);
843
+ span.setStatus('error');
844
+
845
+ if (!suppressTelemetry) {
846
+ recordPushMetrics({
847
+ status: 'error',
848
+ durationMs,
849
+ operationCount,
850
+ emittedChangeCount: 0,
851
+ affectedTableCount: 0,
852
+ });
853
+ captureSyncException(error, {
854
+ event: 'sync.server.push',
855
+ operationCount,
284
856
  });
285
857
  }
858
+ throw error;
859
+ }
860
+ }
861
+ );
862
+ }
286
863
 
287
- return finalizeResult(
288
- await dialect.executeInTransaction(db, async (trx) => {
289
- type SyncTrx = Pick<
290
- Kysely<SyncCoreDb>,
291
- 'selectFrom' | 'insertInto' | 'updateTable' | 'deleteFrom'
292
- >;
293
-
294
- const syncTrx = trx as SyncTrx;
295
-
296
- // Clean up any stale commit row with null result_json.
297
- // This can happen when a previous push inserted the commit row but crashed
298
- // before writing the result (e.g., on D1 without transaction support).
299
- if (!dialect.supportsSavepoints) {
300
- await syncTrx
301
- .deleteFrom('sync_commits')
302
- .where('partition_id', '=', partitionId)
303
- .where('client_id', '=', request.clientId)
304
- .where('client_commit_id', '=', request.clientCommitId)
305
- .where('result_json', 'is', null)
306
- .execute();
307
- }
864
+ export async function pushCommitBatch<
865
+ DB extends SyncCoreDb,
866
+ Auth extends SyncServerAuth,
867
+ >(args: {
868
+ db: Kysely<DB>;
869
+ dialect: ServerSyncDialect;
870
+ handlers: ServerHandlerCollection<DB, Auth>;
871
+ plugins?: readonly SyncServerPushPlugin<DB, Auth>[];
872
+ auth: Auth;
873
+ requests: SyncPushRequest[];
874
+ suppressTelemetry?: boolean;
875
+ }): Promise<PushCommitResult[]> {
876
+ const { db, dialect, handlers, requests } = args;
877
+ const pushPlugins = sortServerPushPlugins(args.plugins);
878
+ const startedAtMs = Date.now();
879
+ const suppressTelemetry = args.suppressTelemetry === true;
880
+ const totalOperationCount = requests.reduce((count, request) => {
881
+ const operations = Array.isArray(request.operations)
882
+ ? request.operations
883
+ : [];
884
+ return count + operations.length;
885
+ }, 0);
308
886
 
309
- // Insert commit row (idempotency key)
310
- const commitRow: Insertable<SyncCoreDb['sync_commits']> = {
311
- partition_id: partitionId,
312
- actor_id: actorId,
313
- client_id: request.clientId,
314
- client_commit_id: request.clientCommitId,
315
- meta: null,
316
- result_json: null,
317
- };
318
-
319
- const loadExistingCommit = async (): Promise<PushCommitResult> => {
320
- // Existing commit: return cached response (applied or rejected)
321
- // Use forUpdate() for row locking on databases that support it
322
- let query = (
323
- syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
324
- SyncCoreDb,
325
- 'sync_commits',
326
- EmptySelection
327
- >
328
- )
329
- .selectAll()
330
- .where('partition_id', '=', partitionId)
331
- .where('client_id', '=', request.clientId)
332
- .where('client_commit_id', '=', request.clientCommitId);
333
-
334
- if (dialect.supportsForUpdate) {
335
- query = query.forUpdate();
336
- }
337
-
338
- const existing = await query.executeTakeFirstOrThrow();
339
-
340
- const parsedCached = parseJsonValue(existing.result_json);
341
- if (!isSyncPushResponse(parsedCached)) {
342
- return {
343
- response: {
344
- ok: true,
345
- status: 'rejected',
346
- results: [
347
- {
348
- opIndex: 0,
349
- status: 'error',
350
- error: 'IDEMPOTENCY_CACHE_MISS',
351
- code: 'INTERNAL',
352
- retriable: true,
353
- },
354
- ],
355
- },
356
- affectedTables: [],
357
- scopeKeys: [],
358
- emittedChanges: [],
359
- commitActorId:
360
- typeof existing.actor_id === 'string'
361
- ? existing.actor_id
362
- : null,
363
- commitCreatedAt:
364
- typeof existing.created_at === 'string'
365
- ? existing.created_at
366
- : null,
367
- };
368
- }
369
-
370
- const base: SyncPushResponse = {
371
- ...parsedCached,
372
- commitSeq: Number(existing.commit_seq),
373
- };
374
-
375
- if (parsedCached.status === 'applied') {
376
- const tablesFromDb = dialect.dbToArray(
377
- existing.affected_tables
378
- );
379
- return {
380
- response: { ...base, status: 'cached' },
381
- affectedTables:
382
- tablesFromDb.length > 0
383
- ? tablesFromDb
384
- : await readCommitAffectedTables(
385
- trx,
386
- dialect,
387
- Number(existing.commit_seq),
388
- partitionId
389
- ),
390
- scopeKeys: [],
391
- emittedChanges: [],
392
- commitActorId:
393
- typeof existing.actor_id === 'string'
394
- ? existing.actor_id
395
- : null,
396
- commitCreatedAt:
397
- typeof existing.created_at === 'string'
398
- ? existing.created_at
399
- : null,
400
- };
401
- }
402
-
403
- return {
404
- response: base,
405
- affectedTables: [],
406
- scopeKeys: [],
407
- emittedChanges: [],
408
- commitActorId:
409
- typeof existing.actor_id === 'string'
410
- ? existing.actor_id
411
- : null,
412
- commitCreatedAt:
413
- typeof existing.created_at === 'string'
414
- ? existing.created_at
415
- : null,
416
- };
417
- };
418
-
419
- const loadPersistedCommitMetadata = async (
420
- seq: number
421
- ): Promise<{
422
- commitActorId: string | null;
423
- commitCreatedAt: string | null;
424
- }> => {
425
- const persisted = await (
426
- syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
427
- SyncCoreDb,
428
- 'sync_commits',
429
- EmptySelection
430
- >
431
- )
432
- .selectAll()
433
- .where('commit_seq', '=', seq)
434
- .where('partition_id', '=', partitionId)
435
- .executeTakeFirst();
436
-
437
- return {
438
- commitActorId:
439
- typeof persisted?.actor_id === 'string'
440
- ? persisted.actor_id
441
- : null,
442
- commitCreatedAt:
443
- typeof persisted?.created_at === 'string'
444
- ? persisted.created_at
445
- : null,
446
- };
447
- };
448
-
449
- let commitSeq = 0;
450
- if (dialect.supportsInsertReturning) {
451
- const insertedCommit = await syncTrx
452
- .insertInto('sync_commits')
453
- .values(commitRow)
454
- .onConflict((oc) =>
455
- oc
456
- .columns(['partition_id', 'client_id', 'client_commit_id'])
457
- .doNothing()
458
- )
459
- .returning(['commit_seq'])
460
- .executeTakeFirst();
461
-
462
- if (!insertedCommit) {
463
- return loadExistingCommit();
464
- }
465
-
466
- commitSeq = coerceNumber(insertedCommit.commit_seq) ?? 0;
467
- } else {
468
- const insertResult = await syncTrx
469
- .insertInto('sync_commits')
470
- .values(commitRow)
471
- .onConflict((oc) =>
472
- oc
473
- .columns(['partition_id', 'client_id', 'client_commit_id'])
474
- .doNothing()
475
- )
476
- .executeTakeFirstOrThrow();
477
-
478
- const insertedRows = Number(
479
- insertResult.numInsertedOrUpdatedRows ?? 0
480
- );
481
- if (insertedRows === 0) {
482
- return loadExistingCommit();
483
- }
484
-
485
- commitSeq = coerceNumber(insertResult.insertId) ?? 0;
887
+ return startSyncSpan(
888
+ {
889
+ name: 'sync.server.push_batch',
890
+ op: 'sync.push.batch',
891
+ attributes: {
892
+ commit_count: requests.length,
893
+ operation_count: totalOperationCount,
894
+ },
895
+ },
896
+ async (span) => {
897
+ try {
898
+ const results = await dialect.executeInTransaction(db, async (trx) => {
899
+ const executed: PushCommitResult[] = [];
900
+ for (const request of requests) {
901
+ const validationError = validatePushRequest(request);
902
+ if (validationError) {
903
+ executed.push(createRejectedPushResult(validationError));
904
+ continue;
486
905
  }
487
906
 
488
- if (commitSeq <= 0) {
489
- const insertedCommitRow = await (
490
- syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
491
- SyncCoreDb,
492
- 'sync_commits',
493
- EmptySelection
494
- >
495
- )
496
- .selectAll()
497
- .where('partition_id', '=', partitionId)
498
- .where('client_id', '=', request.clientId)
499
- .where('client_commit_id', '=', request.clientCommitId)
500
- .executeTakeFirstOrThrow();
501
- commitSeq = Number(insertedCommitRow.commit_seq);
502
- }
503
- const commitId = `${request.clientId}:${request.clientCommitId}`;
504
-
505
- const savepointName = 'sync_apply';
506
- let useSavepoints = dialect.supportsSavepoints;
507
- if (useSavepoints && ops.length === 1) {
508
- const singleOpHandler = handlers.byTable.get(ops[0]!.table);
509
- if (!singleOpHandler) {
510
- throw new Error(`Unknown table: ${ops[0]!.table}`);
511
- }
512
- if (singleOpHandler.canRejectSingleOperationWithoutSavepoint) {
513
- useSavepoints = false;
514
- }
515
- }
516
- let savepointCreated = false;
517
-
518
- try {
519
- // Apply the commit under a savepoint so we can roll back app writes on conflict
520
- // while still persisting the commit-level cached response.
521
- if (useSavepoints) {
522
- await sql.raw(`SAVEPOINT ${savepointName}`).execute(trx);
523
- savepointCreated = true;
524
- }
525
-
526
- const allEmitted = [];
527
- const results = [];
528
- const affectedTablesSet = new Set<string>();
529
-
530
- for (let i = 0; i < ops.length; ) {
531
- const op = ops[i]!;
532
- const handler = handlers.byTable.get(op.table);
533
- if (!handler) {
534
- throw new Error(`Unknown table: ${op.table}`);
535
- }
536
-
537
- const operationCtx = {
538
- db: trx,
539
- trx,
540
- actorId,
541
- auth: args.auth,
542
- clientId: request.clientId,
543
- commitId,
544
- schemaVersion: request.schemaVersion,
545
- };
546
-
547
- let transformedOp = op;
548
- for (const plugin of pushPlugins) {
549
- if (!plugin.beforeApplyOperation) continue;
550
- const nextOp = await plugin.beforeApplyOperation({
551
- ctx: operationCtx,
552
- tableHandler: handler,
553
- op: transformedOp,
554
- opIndex: i,
555
- });
556
- assertOperationIdentityUnchanged(plugin.name, op, nextOp);
557
- transformedOp = nextOp;
558
- }
559
-
560
- let appliedBatch:
561
- | Awaited<ReturnType<typeof handler.applyOperation>>[]
562
- | null = null;
563
- let consumed = 1;
564
-
565
- if (
566
- pushPlugins.length === 0 &&
567
- handler.applyOperationBatch &&
568
- dialect.supportsInsertReturning
569
- ) {
570
- const batchInput = [];
571
- for (let j = i; j < ops.length; j++) {
572
- const nextOp = ops[j]!;
573
- if (nextOp.table !== op.table) break;
574
- batchInput.push({ op: nextOp, opIndex: j });
575
- }
576
-
577
- if (batchInput.length > 1) {
578
- appliedBatch = await handler.applyOperationBatch(
579
- operationCtx,
580
- batchInput
581
- );
582
- consumed = Math.max(1, appliedBatch.length);
583
- }
584
- }
585
-
586
- if (!appliedBatch) {
587
- let appliedSingle = await handler.applyOperation(
588
- operationCtx,
589
- transformedOp,
590
- i
591
- );
592
-
593
- for (const plugin of pushPlugins) {
594
- if (!plugin.afterApplyOperation) continue;
595
- appliedSingle = await plugin.afterApplyOperation({
596
- ctx: operationCtx,
597
- tableHandler: handler,
598
- op: transformedOp,
599
- opIndex: i,
600
- applied: appliedSingle,
601
- });
602
- }
603
-
604
- appliedBatch = [appliedSingle];
605
- }
606
- if (appliedBatch.length === 0) {
607
- throw new Error(
608
- `Handler "${op.table}" returned no results from applyOperationBatch`
609
- );
610
- }
611
-
612
- for (const applied of appliedBatch) {
613
- if (applied.result.status !== 'applied') {
614
- results.push(applied.result);
615
- throw new RejectCommitError({
616
- ok: true,
617
- status: 'rejected',
618
- commitSeq,
619
- results,
620
- });
621
- }
622
-
623
- // Framework-level enforcement: emitted changes must have scopes
624
- for (const c of applied.emittedChanges ?? []) {
625
- const scopes = c?.scopes;
626
- if (!scopes || typeof scopes !== 'object') {
627
- results.push({
628
- opIndex: applied.result.opIndex,
629
- status: 'error' as const,
630
- error: 'MISSING_SCOPES',
631
- code: 'INVALID_SCOPE',
632
- retriable: false,
633
- });
634
- throw new RejectCommitError({
635
- ok: true,
636
- status: 'rejected',
637
- commitSeq,
638
- results,
639
- });
640
- }
641
- }
642
-
643
- results.push(applied.result);
644
- allEmitted.push(...applied.emittedChanges);
645
- for (const c of applied.emittedChanges) {
646
- affectedTablesSet.add(c.table);
647
- }
648
- }
649
-
650
- i += consumed;
651
- }
652
-
653
- if (allEmitted.length > 0) {
654
- const changeRows: Array<
655
- Insertable<SyncCoreDb['sync_changes']>
656
- > = allEmitted.map((c) => ({
657
- partition_id: partitionId,
658
- commit_seq: commitSeq,
659
- table: c.table,
660
- row_id: c.row_id,
661
- op: c.op,
662
- row_json: toDialectJsonValue(dialect, c.row_json),
663
- row_version: c.row_version,
664
- scopes: dialect.scopesToDb(c.scopes),
665
- }));
666
-
667
- await syncTrx
668
- .insertInto('sync_changes')
669
- .values(changeRows)
670
- .execute();
671
- }
672
-
673
- const appliedResponse: SyncPushResponse = {
674
- ok: true,
675
- status: 'applied',
676
- commitSeq,
677
- results,
678
- };
679
-
680
- const affectedTables = Array.from(affectedTablesSet).sort();
681
-
682
- const appliedCommitUpdate: Updateable<
683
- SyncCoreDb['sync_commits']
684
- > = {
685
- result_json: toDialectJsonValue(dialect, appliedResponse),
686
- change_count: allEmitted.length,
687
- affected_tables: dialect.arrayToDb(affectedTables) as string[],
688
- };
689
-
690
- await syncTrx
691
- .updateTable('sync_commits')
692
- .set(appliedCommitUpdate)
693
- .where('commit_seq', '=', commitSeq)
694
- .execute();
695
-
696
- // Insert table commits for subscription filtering
697
- if (affectedTables.length > 0) {
698
- const tableCommits: Array<
699
- Insertable<SyncCoreDb['sync_table_commits']>
700
- > = affectedTables.map((table) => ({
701
- partition_id: partitionId,
702
- table,
703
- commit_seq: commitSeq,
704
- }));
705
-
706
- await syncTrx
707
- .insertInto('sync_table_commits')
708
- .values(tableCommits)
709
- .onConflict((oc) =>
710
- oc
711
- .columns(['partition_id', 'table', 'commit_seq'])
712
- .doNothing()
713
- )
714
- .execute();
715
- }
716
-
717
- if (useSavepoints) {
718
- await sql
719
- .raw(`RELEASE SAVEPOINT ${savepointName}`)
720
- .execute(trx);
721
- }
722
-
723
- const commitMetadata =
724
- await loadPersistedCommitMetadata(commitSeq);
725
-
726
- return {
727
- response: appliedResponse,
728
- affectedTables,
729
- scopeKeys: scopeKeysFromEmitted(allEmitted),
730
- emittedChanges: allEmitted.map((c) => ({
731
- table: c.table,
732
- row_id: c.row_id,
733
- op: c.op,
734
- row_json: c.row_json,
735
- row_version: c.row_version,
736
- scopes: c.scopes,
737
- })),
738
- ...commitMetadata,
739
- };
740
- } catch (err) {
741
- // Roll back app writes but keep the commit row.
742
- if (savepointCreated) {
743
- try {
744
- await sql
745
- .raw(`ROLLBACK TO SAVEPOINT ${savepointName}`)
746
- .execute(trx);
747
- await sql
748
- .raw(`RELEASE SAVEPOINT ${savepointName}`)
749
- .execute(trx);
750
- } catch (savepointErr) {
751
- // If savepoint rollback fails, the transaction may be in an
752
- // inconsistent state. Log and rethrow to fail the entire commit
753
- // rather than risk data corruption.
754
- console.error(
755
- '[pushCommit] Savepoint rollback failed:',
756
- savepointErr
757
- );
758
- throw savepointErr;
759
- }
760
- }
761
-
762
- if (!(err instanceof RejectCommitError)) throw err;
763
-
764
- const rejectedCommitUpdate: Updateable<
765
- SyncCoreDb['sync_commits']
766
- > = {
767
- result_json: toDialectJsonValue(dialect, err.response),
768
- change_count: 0,
769
- affected_tables: dialect.arrayToDb([]) as string[],
770
- };
771
-
772
- // Persist the rejected response for commit-level idempotency.
773
- await syncTrx
774
- .updateTable('sync_commits')
775
- .set(rejectedCommitUpdate)
776
- .where('commit_seq', '=', commitSeq)
777
- .execute();
778
-
779
- const commitMetadata =
780
- await loadPersistedCommitMetadata(commitSeq);
781
-
782
- return {
783
- response: err.response,
784
- affectedTables: [],
785
- scopeKeys: [],
786
- emittedChanges: [],
787
- ...commitMetadata,
788
- };
789
- }
790
- })
907
+ executed.push(
908
+ await executePushCommitInExecutor({
909
+ trx,
910
+ dialect,
911
+ handlers,
912
+ pushPlugins,
913
+ auth: args.auth,
914
+ request,
915
+ })
916
+ );
917
+ }
918
+ return executed;
919
+ });
920
+
921
+ const durationMs = Math.max(0, Date.now() - startedAtMs);
922
+ const emittedChangeCount = results.reduce(
923
+ (count, result) => count + result.emittedChanges.length,
924
+ 0
791
925
  );
926
+ const affectedTableCount = results.reduce(
927
+ (count, result) => count + result.affectedTables.length,
928
+ 0
929
+ );
930
+ const status = results.every(
931
+ (result) => result.response.status === 'cached'
932
+ )
933
+ ? 'cached'
934
+ : results.every(
935
+ (result) =>
936
+ result.response.status === 'applied' ||
937
+ result.response.status === 'cached'
938
+ )
939
+ ? 'applied'
940
+ : 'rejected';
941
+
942
+ span.setAttribute('status', status);
943
+ span.setAttribute('duration_ms', durationMs);
944
+ span.setAttribute('commit_count', results.length);
945
+ span.setAttribute('emitted_change_count', emittedChangeCount);
946
+ span.setAttribute('affected_table_count', affectedTableCount);
947
+ span.setStatus('ok');
948
+
949
+ if (!suppressTelemetry) {
950
+ recordPushMetrics({
951
+ status,
952
+ durationMs,
953
+ operationCount: totalOperationCount,
954
+ emittedChangeCount,
955
+ affectedTableCount,
956
+ });
957
+ }
958
+
959
+ return results;
792
960
  } catch (error) {
793
961
  const durationMs = Math.max(0, Date.now() - startedAtMs);
794
962
  span.setAttribute('status', 'error');
795
963
  span.setAttribute('duration_ms', durationMs);
796
964
  span.setStatus('error');
797
965
 
798
- recordPushMetrics({
799
- status: 'error',
800
- durationMs,
801
- operationCount,
802
- emittedChangeCount: 0,
803
- affectedTableCount: 0,
804
- });
805
- captureSyncException(error, {
806
- event: 'sync.server.push',
807
- operationCount,
808
- });
966
+ if (!suppressTelemetry) {
967
+ recordPushMetrics({
968
+ status: 'error',
969
+ durationMs,
970
+ operationCount: totalOperationCount,
971
+ emittedChangeCount: 0,
972
+ affectedTableCount: 0,
973
+ });
974
+ captureSyncException(error, {
975
+ event: 'sync.server.push_batch',
976
+ commitCount: requests.length,
977
+ operationCount: totalOperationCount,
978
+ });
979
+ }
809
980
  throw error;
810
981
  }
811
982
  }