@syncular/server 0.0.6-126 → 0.0.6-135

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.
@@ -43,6 +43,11 @@ export interface ApplyOperationResult {
43
43
  emittedChanges: EmittedChange[];
44
44
  }
45
45
 
46
+ export interface BatchApplyOperationInput {
47
+ op: SyncOperation;
48
+ opIndex: number;
49
+ }
50
+
46
51
  /**
47
52
  * Context for server operations.
48
53
  */
@@ -258,6 +263,18 @@ export interface ServerTableHandler<
258
263
  */
259
264
  snapshotChunkTtlMs?: number;
260
265
 
266
+ /**
267
+ * Hint for push engine savepoint optimization on single-op commits.
268
+ *
269
+ * When true, the handler guarantees that a rejected single operation
270
+ * does not leave durable writes behind before returning the rejection.
271
+ * This allows pushCommit() to skip SAVEPOINT overhead for single-op
272
+ * commits on savepoint-capable dialects.
273
+ *
274
+ * Omit or set false for custom handlers unless this guarantee holds.
275
+ */
276
+ canRejectSingleOperationWithoutSavepoint?: boolean;
277
+
261
278
  /**
262
279
  * Resolve allowed scope values for the current actor.
263
280
  */
@@ -284,4 +301,17 @@ export interface ServerTableHandler<
284
301
  op: SyncOperation,
285
302
  opIndex: number
286
303
  ): Promise<ApplyOperationResult>;
304
+
305
+ /**
306
+ * Apply multiple operations in order.
307
+ *
308
+ * Implementations may internally batch writes but must preserve the same
309
+ * observable semantics as sequential applyOperation calls:
310
+ * - results are returned in operation order
311
+ * - processing stops at the first non-applied result
312
+ */
313
+ applyOperationBatch?(
314
+ ctx: ServerApplyOperationContext<DB, Auth>,
315
+ operations: BatchApplyOperationInput[]
316
+ ): Promise<ApplyOperationResult[]>;
287
317
  }
package/src/notify.ts CHANGED
@@ -106,12 +106,22 @@ export async function notifyExternalDataChange<DB extends SyncCoreDb>(
106
106
  affected_tables: dialect.arrayToDb(tables) as string[],
107
107
  };
108
108
 
109
- const insertResult = await syncTrx
110
- .insertInto('sync_commits')
111
- .values(commitRow)
112
- .executeTakeFirstOrThrow();
109
+ let commitSeq = 0;
110
+ if (dialect.supportsInsertReturning) {
111
+ const insertedCommit = await syncTrx
112
+ .insertInto('sync_commits')
113
+ .values(commitRow)
114
+ .returning(['commit_seq'])
115
+ .executeTakeFirstOrThrow();
116
+ commitSeq = coerceNumber(insertedCommit.commit_seq) ?? 0;
117
+ } else {
118
+ const insertResult = await syncTrx
119
+ .insertInto('sync_commits')
120
+ .values(commitRow)
121
+ .executeTakeFirstOrThrow();
122
+ commitSeq = coerceNumber(insertResult.insertId) ?? 0;
123
+ }
113
124
 
114
- let commitSeq = coerceNumber(insertResult.insertId) ?? 0;
115
125
  if (commitSeq <= 0) {
116
126
  // Fallback for dialects/drivers that don't provide insertId.
117
127
  const inserted = await syncTrx
package/src/push.ts CHANGED
@@ -306,20 +306,7 @@ export async function pushCommit<
306
306
  result_json: null,
307
307
  };
308
308
 
309
- const insertResult = await syncTrx
310
- .insertInto('sync_commits')
311
- .values(commitRow)
312
- .onConflict((oc) =>
313
- oc
314
- .columns(['partition_id', 'client_id', 'client_commit_id'])
315
- .doNothing()
316
- )
317
- .executeTakeFirstOrThrow();
318
-
319
- const insertedRows = Number(
320
- insertResult.numInsertedOrUpdatedRows ?? 0
321
- );
322
- if (insertedRows === 0) {
309
+ const loadExistingCommit = async (): Promise<PushCommitResult> => {
323
310
  // Existing commit: return cached response (applied or rejected)
324
311
  // Use forUpdate() for row locking on databases that support it
325
312
  let query = (
@@ -393,11 +380,49 @@ export async function pushCommit<
393
380
  scopeKeys: [],
394
381
  emittedChanges: [],
395
382
  };
383
+ };
384
+
385
+ let commitSeq = 0;
386
+ if (dialect.supportsInsertReturning) {
387
+ const insertedCommit = await syncTrx
388
+ .insertInto('sync_commits')
389
+ .values(commitRow)
390
+ .onConflict((oc) =>
391
+ oc
392
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
393
+ .doNothing()
394
+ )
395
+ .returning(['commit_seq'])
396
+ .executeTakeFirst();
397
+
398
+ if (!insertedCommit) {
399
+ return loadExistingCommit();
400
+ }
401
+
402
+ commitSeq = coerceNumber(insertedCommit.commit_seq) ?? 0;
403
+ } else {
404
+ const insertResult = await syncTrx
405
+ .insertInto('sync_commits')
406
+ .values(commitRow)
407
+ .onConflict((oc) =>
408
+ oc
409
+ .columns(['partition_id', 'client_id', 'client_commit_id'])
410
+ .doNothing()
411
+ )
412
+ .executeTakeFirstOrThrow();
413
+
414
+ const insertedRows = Number(
415
+ insertResult.numInsertedOrUpdatedRows ?? 0
416
+ );
417
+ if (insertedRows === 0) {
418
+ return loadExistingCommit();
419
+ }
420
+
421
+ commitSeq = coerceNumber(insertResult.insertId) ?? 0;
396
422
  }
397
423
 
398
- let commitSeq = coerceNumber(insertResult.insertId) ?? 0;
399
424
  if (commitSeq <= 0) {
400
- const insertedCommit = await (
425
+ const insertedCommitRow = await (
401
426
  syncTrx.selectFrom('sync_commits') as SelectQueryBuilder<
402
427
  SyncCoreDb,
403
428
  'sync_commits',
@@ -409,12 +434,21 @@ export async function pushCommit<
409
434
  .where('client_id', '=', request.clientId)
410
435
  .where('client_commit_id', '=', request.clientCommitId)
411
436
  .executeTakeFirstOrThrow();
412
- commitSeq = Number(insertedCommit.commit_seq);
437
+ commitSeq = Number(insertedCommitRow.commit_seq);
413
438
  }
414
439
  const commitId = `${request.clientId}:${request.clientCommitId}`;
415
440
 
416
441
  const savepointName = 'sync_apply';
417
- const useSavepoints = dialect.supportsSavepoints;
442
+ let useSavepoints = dialect.supportsSavepoints;
443
+ if (useSavepoints && ops.length === 1) {
444
+ const singleOpHandler = getServerHandlerOrThrow(
445
+ handlers,
446
+ ops[0]!.table
447
+ );
448
+ if (singleOpHandler.canRejectSingleOperationWithoutSavepoint) {
449
+ useSavepoints = false;
450
+ }
451
+ }
418
452
  let savepointCreated = false;
419
453
 
420
454
  try {
@@ -429,44 +463,59 @@ export async function pushCommit<
429
463
  const results = [];
430
464
  const affectedTablesSet = new Set<string>();
431
465
 
432
- for (let i = 0; i < ops.length; i++) {
466
+ for (let i = 0; i < ops.length; ) {
433
467
  const op = ops[i]!;
434
468
  const handler = getServerHandlerOrThrow(handlers, op.table);
435
- const applied = await handler.applyOperation(
436
- {
437
- db: trx,
438
- trx,
439
- actorId,
440
- auth: args.auth,
441
- clientId: request.clientId,
442
- commitId,
443
- schemaVersion: request.schemaVersion,
444
- },
445
- op,
446
- i
447
- );
448
469
 
449
- if (applied.result.status !== 'applied') {
450
- results.push(applied.result);
451
- throw new RejectCommitError({
452
- ok: true,
453
- status: 'rejected',
454
- commitSeq,
455
- results,
456
- });
470
+ const operationCtx = {
471
+ db: trx,
472
+ trx,
473
+ actorId,
474
+ auth: args.auth,
475
+ clientId: request.clientId,
476
+ commitId,
477
+ schemaVersion: request.schemaVersion,
478
+ };
479
+
480
+ let appliedBatch:
481
+ | Awaited<ReturnType<typeof handler.applyOperation>>[]
482
+ | null = null;
483
+ let consumed = 1;
484
+
485
+ if (
486
+ handler.applyOperationBatch &&
487
+ dialect.supportsInsertReturning
488
+ ) {
489
+ const batchInput = [];
490
+ for (let j = i; j < ops.length; j++) {
491
+ const nextOp = ops[j]!;
492
+ if (nextOp.table !== op.table) break;
493
+ batchInput.push({ op: nextOp, opIndex: j });
494
+ }
495
+
496
+ if (batchInput.length > 1) {
497
+ appliedBatch = await handler.applyOperationBatch(
498
+ operationCtx,
499
+ batchInput
500
+ );
501
+ consumed = Math.max(1, appliedBatch.length);
502
+ }
457
503
  }
458
504
 
459
- // Framework-level enforcement: emitted changes must have scopes
460
- for (const c of applied.emittedChanges ?? []) {
461
- const scopes = c?.scopes;
462
- if (!scopes || typeof scopes !== 'object') {
463
- results.push({
464
- opIndex: i,
465
- status: 'error' as const,
466
- error: 'MISSING_SCOPES',
467
- code: 'INVALID_SCOPE',
468
- retriable: false,
469
- });
505
+ if (!appliedBatch) {
506
+ appliedBatch = [
507
+ await handler.applyOperation(operationCtx, op, i),
508
+ ];
509
+ }
510
+ if (appliedBatch.length === 0) {
511
+ throw new Error(
512
+ `Handler "${op.table}" returned no results from applyOperationBatch`
513
+ );
514
+ }
515
+
516
+ for (const applied of appliedBatch) {
517
+ if (applied.result.status !== 'applied') {
518
+ results.push(applied.result);
470
519
  throw new RejectCommitError({
471
520
  ok: true,
472
521
  status: 'rejected',
@@ -474,13 +523,35 @@ export async function pushCommit<
474
523
  results,
475
524
  });
476
525
  }
477
- }
478
526
 
479
- results.push(applied.result);
480
- allEmitted.push(...applied.emittedChanges);
481
- for (const c of applied.emittedChanges) {
482
- affectedTablesSet.add(c.table);
527
+ // Framework-level enforcement: emitted changes must have scopes
528
+ for (const c of applied.emittedChanges ?? []) {
529
+ const scopes = c?.scopes;
530
+ if (!scopes || typeof scopes !== 'object') {
531
+ results.push({
532
+ opIndex: applied.result.opIndex,
533
+ status: 'error' as const,
534
+ error: 'MISSING_SCOPES',
535
+ code: 'INVALID_SCOPE',
536
+ retriable: false,
537
+ });
538
+ throw new RejectCommitError({
539
+ ok: true,
540
+ status: 'rejected',
541
+ commitSeq,
542
+ results,
543
+ });
544
+ }
545
+ }
546
+
547
+ results.push(applied.result);
548
+ allEmitted.push(...applied.emittedChanges);
549
+ for (const c of applied.emittedChanges) {
550
+ affectedTablesSet.add(c.table);
551
+ }
483
552
  }
553
+
554
+ i += consumed;
484
555
  }
485
556
 
486
557
  if (allEmitted.length > 0) {