@syncular/server-hono 0.0.6-184 → 0.0.6-188

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/routes.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  ScopeValuesSchema,
18
18
  SyncCombinedRequestSchema,
19
19
  SyncCombinedResponseSchema,
20
+ type SyncPushCommitRequestSchema,
20
21
  SyncPushRequestSchema,
22
+ type SyncPushResponse,
21
23
  } from '@syncular/core';
22
24
  import type {
23
25
  ScopeCacheBackend,
@@ -42,6 +44,7 @@ import {
42
44
  parseJsonValue,
43
45
  pull,
44
46
  pushCommit,
47
+ pushCommitBatch,
45
48
  readSnapshotChunk,
46
49
  recordClientCursor,
47
50
  resolveEffectiveScopesForSubscriptions,
@@ -880,8 +883,8 @@ export function createSyncRoutes<
880
883
  };
881
884
 
882
885
  type PushRequestBody = Omit<
883
- z.infer<typeof SyncPushRequestSchema>,
884
- 'clientId'
886
+ z.infer<typeof SyncPushCommitRequestSchema>,
887
+ never
885
888
  >;
886
889
 
887
890
  type PushExecutionContext = {
@@ -894,11 +897,291 @@ export function createSyncRoutes<
894
897
  syncPath: 'http-combined' | 'ws-push';
895
898
  };
896
899
 
900
+ type ExecutedPushCommit = Awaited<ReturnType<typeof pushCommit>>;
901
+
902
+ type PushExecutionSummary = {
903
+ durationMs: number;
904
+ outcome: string;
905
+ commitSeq: number | null;
906
+ operationCount: number;
907
+ tables: string[];
908
+ results: SyncPushResponse['results'];
909
+ payloadSnapshot: RequestPayloadSnapshot | null;
910
+ };
911
+
912
+ function notifyRealtimeForAppliedPushes(
913
+ ctx: PushExecutionContext,
914
+ pushedCommits: ExecutedPushCommit[]
915
+ ): void {
916
+ if (!wsConnectionManager && !realtimeBroadcaster) {
917
+ return;
918
+ }
919
+
920
+ let latestCommitSeq = 0;
921
+ let latestActorId = ctx.auth.actorId;
922
+ let latestCreatedAt: string | undefined;
923
+ const scopeKeys = new Set<string>();
924
+ const emittedChanges: ExecutedPushCommit['emittedChanges'] = [];
925
+
926
+ for (const pushed of pushedCommits) {
927
+ if (
928
+ pushed.response.ok !== true ||
929
+ pushed.response.status !== 'applied' ||
930
+ typeof pushed.response.commitSeq !== 'number'
931
+ ) {
932
+ continue;
933
+ }
934
+
935
+ latestCommitSeq = Math.max(latestCommitSeq, pushed.response.commitSeq);
936
+ latestActorId = pushed.commitActorId ?? latestActorId;
937
+ latestCreatedAt = pushed.commitCreatedAt ?? latestCreatedAt;
938
+ for (const scopeKey of applyPartitionToScopeKeys(
939
+ ctx.partitionId,
940
+ pushed.scopeKeys
941
+ )) {
942
+ scopeKeys.add(scopeKey);
943
+ }
944
+ emittedChanges.push(...pushed.emittedChanges);
945
+ }
946
+
947
+ if (latestCommitSeq <= 0 || scopeKeys.size === 0) {
948
+ return;
949
+ }
950
+
951
+ const combinedScopeKeys = Array.from(scopeKeys);
952
+
953
+ if (wsConnectionManager) {
954
+ wsConnectionManager.notifyScopeKeys(combinedScopeKeys, latestCommitSeq, {
955
+ excludeClientIds: [ctx.clientId],
956
+ changes: emittedChanges,
957
+ actorId: latestActorId,
958
+ createdAt: latestCreatedAt,
959
+ });
960
+ }
961
+
962
+ if (realtimeBroadcaster) {
963
+ realtimeBroadcaster
964
+ .publish({
965
+ type: 'commit',
966
+ commitSeq: latestCommitSeq,
967
+ partitionId: ctx.partitionId,
968
+ scopeKeys: combinedScopeKeys,
969
+ sourceInstanceId: instanceId,
970
+ })
971
+ .catch((error) => {
972
+ logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
973
+ event: 'sync.realtime.broadcast_publish_failed',
974
+ userId: ctx.auth.actorId,
975
+ clientId: ctx.clientId,
976
+ error: error instanceof Error ? error.message : String(error),
977
+ });
978
+ });
979
+ }
980
+ }
981
+
982
+ function recordPushExecutionSideEffects(
983
+ ctx: PushExecutionContext,
984
+ summary: PushExecutionSummary
985
+ ): void {
986
+ recordRequestEventInBackground(() => ({
987
+ partitionId: ctx.partitionId,
988
+ requestId: ctx.requestId,
989
+ traceId: ctx.traceContext.traceId,
990
+ spanId: ctx.traceContext.spanId,
991
+ eventType: 'push',
992
+ syncPath: ctx.syncPath,
993
+ actorId: ctx.auth.actorId,
994
+ clientId: ctx.clientId,
995
+ transportPath: ctx.transportPath,
996
+ statusCode: 200,
997
+ outcome: summary.outcome,
998
+ responseStatus: normalizeResponseStatus(200, summary.outcome),
999
+ durationMs: summary.durationMs,
1000
+ errorCode: firstPushErrorCode(summary.results),
1001
+ commitSeq: summary.commitSeq,
1002
+ operationCount: summary.operationCount,
1003
+ tables: summary.tables,
1004
+ payloadSnapshot: summary.payloadSnapshot,
1005
+ }));
1006
+
1007
+ emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
1008
+ partitionId: ctx.partitionId,
1009
+ requestId: ctx.requestId,
1010
+ traceId: ctx.traceContext.traceId,
1011
+ spanId: ctx.traceContext.spanId,
1012
+ actorId: ctx.auth.actorId,
1013
+ clientId: ctx.clientId,
1014
+ transportPath: ctx.transportPath,
1015
+ syncPath: ctx.syncPath,
1016
+ outcome: summary.outcome,
1017
+ statusCode: 200,
1018
+ durationMs: summary.durationMs,
1019
+ commitSeq: summary.commitSeq,
1020
+ operationCount: summary.operationCount,
1021
+ tables: summary.tables,
1022
+ }));
1023
+ }
1024
+
1025
+ function maybeCountPushConflicts(
1026
+ ctx: PushExecutionContext,
1027
+ results: SyncPushResponse['results'],
1028
+ enabled?: boolean
1029
+ ): void {
1030
+ if (enabled !== true) {
1031
+ return;
1032
+ }
1033
+
1034
+ const detectedConflicts = results.reduce(
1035
+ (count, result) => count + (result.status === 'conflict' ? 1 : 0),
1036
+ 0
1037
+ );
1038
+ if (detectedConflicts <= 0) {
1039
+ return;
1040
+ }
1041
+
1042
+ countSyncMetric('sync.conflicts.detected', detectedConflicts, {
1043
+ attributes: {
1044
+ syncPath: ctx.syncPath,
1045
+ transportPath: ctx.transportPath,
1046
+ },
1047
+ });
1048
+ }
1049
+
1050
+ function emitCommitLiveEvents(
1051
+ ctx: PushExecutionContext,
1052
+ pushedCommits: ExecutedPushCommit[]
1053
+ ): void {
1054
+ for (const pushed of pushedCommits) {
1055
+ if (
1056
+ pushed.response.ok !== true ||
1057
+ pushed.response.status !== 'applied' ||
1058
+ typeof pushed.response.commitSeq !== 'number'
1059
+ ) {
1060
+ continue;
1061
+ }
1062
+
1063
+ emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
1064
+ partitionId: ctx.partitionId,
1065
+ commitSeq: pushed.response.commitSeq,
1066
+ actorId: ctx.auth.actorId,
1067
+ clientId: ctx.clientId,
1068
+ affectedTables: pushed.affectedTables,
1069
+ }));
1070
+ }
1071
+ }
1072
+
1073
+ async function executePushCommitBatchWithSideEffects(
1074
+ ctx: PushExecutionContext,
1075
+ pushBodies: PushRequestBody[],
1076
+ execOptions: {
1077
+ countConflictsMetric?: boolean;
1078
+ } = {}
1079
+ ): Promise<ExecutedPushCommit[]> {
1080
+ const timer = createSyncTimer();
1081
+ const totalOperationCount = pushBodies.reduce(
1082
+ (count, pushBody) => count + (pushBody.operations?.length ?? 0),
1083
+ 0
1084
+ );
1085
+ const executedPushes = await pushCommitBatch({
1086
+ db: options.db,
1087
+ dialect: options.dialect,
1088
+ handlers: handlerRegistry,
1089
+ plugins: options.plugins,
1090
+ auth: ctx.auth,
1091
+ suppressTelemetry: true,
1092
+ requests: pushBodies.map((pushBody) => ({
1093
+ clientId: ctx.clientId,
1094
+ clientCommitId: pushBody.clientCommitId,
1095
+ operations: pushBody.operations,
1096
+ schemaVersion: pushBody.schemaVersion,
1097
+ })),
1098
+ });
1099
+ const affectedTables = new Set<string>();
1100
+ for (const pushed of executedPushes) {
1101
+ for (const table of pushed.affectedTables) {
1102
+ affectedTables.add(table);
1103
+ }
1104
+ }
1105
+
1106
+ const pushDurationMs = timer();
1107
+ const latestCommitSeq = executedPushes.reduce((latest, pushed) => {
1108
+ if (typeof pushed.response.commitSeq === 'number') {
1109
+ return Math.max(latest, pushed.response.commitSeq);
1110
+ }
1111
+ return latest;
1112
+ }, 0);
1113
+ const aggregateStatus = executedPushes.every(
1114
+ (pushed) => pushed.response.status === 'cached'
1115
+ )
1116
+ ? 'cached'
1117
+ : executedPushes.every(
1118
+ (pushed) =>
1119
+ pushed.response.status === 'applied' ||
1120
+ pushed.response.status === 'cached'
1121
+ )
1122
+ ? 'applied'
1123
+ : 'rejected';
1124
+ const aggregatedResults = executedPushes.flatMap(
1125
+ (pushed) => pushed.response.results
1126
+ );
1127
+
1128
+ logSyncEvent({
1129
+ event: 'sync.push',
1130
+ userId: ctx.auth.actorId,
1131
+ durationMs: pushDurationMs,
1132
+ operationCount: totalOperationCount,
1133
+ status: aggregateStatus,
1134
+ commitSeq: latestCommitSeq > 0 ? latestCommitSeq : undefined,
1135
+ });
1136
+
1137
+ recordPushExecutionSideEffects(ctx, {
1138
+ durationMs: pushDurationMs,
1139
+ outcome: aggregateStatus,
1140
+ commitSeq: latestCommitSeq > 0 ? latestCommitSeq : null,
1141
+ operationCount: totalOperationCount,
1142
+ tables: Array.from(affectedTables),
1143
+ results: aggregatedResults,
1144
+ payloadSnapshot: shouldCaptureRequestPayloadSnapshots
1145
+ ? {
1146
+ request: {
1147
+ clientId: ctx.clientId,
1148
+ commits: pushBodies.map((pushBody) => ({
1149
+ clientCommitId: pushBody.clientCommitId,
1150
+ schemaVersion: pushBody.schemaVersion,
1151
+ operations: pushBody.operations,
1152
+ })),
1153
+ },
1154
+ response: {
1155
+ ok: true,
1156
+ commits: executedPushes.map((pushed, index) => ({
1157
+ clientCommitId: pushBodies[index]?.clientCommitId ?? '',
1158
+ ...pushed.response,
1159
+ })),
1160
+ },
1161
+ }
1162
+ : null,
1163
+ });
1164
+
1165
+ maybeCountPushConflicts(
1166
+ ctx,
1167
+ aggregatedResults,
1168
+ execOptions.countConflictsMetric
1169
+ );
1170
+
1171
+ notifyRealtimeForAppliedPushes(ctx, executedPushes);
1172
+ emitCommitLiveEvents(ctx, executedPushes);
1173
+
1174
+ return executedPushes;
1175
+ }
1176
+
897
1177
  async function executePushCommitWithSideEffects(
898
1178
  ctx: PushExecutionContext,
899
1179
  pushBody: PushRequestBody,
900
- execOptions: { countConflictsMetric?: boolean } = {}
901
- ): Promise<Awaited<ReturnType<typeof pushCommit>>> {
1180
+ execOptions: {
1181
+ countConflictsMetric?: boolean;
1182
+ deferRealtimeNotifications?: boolean;
1183
+ } = {}
1184
+ ): Promise<ExecutedPushCommit> {
902
1185
  const timer = createSyncTimer();
903
1186
  const pushOps = pushBody.operations ?? [];
904
1187
 
@@ -927,24 +1210,13 @@ export function createSyncRoutes<
927
1210
  commitSeq: pushed.response.commitSeq,
928
1211
  });
929
1212
 
930
- recordRequestEventInBackground(() => ({
931
- partitionId: ctx.partitionId,
932
- requestId: ctx.requestId,
933
- traceId: ctx.traceContext.traceId,
934
- spanId: ctx.traceContext.spanId,
935
- eventType: 'push',
936
- syncPath: ctx.syncPath,
937
- actorId: ctx.auth.actorId,
938
- clientId: ctx.clientId,
939
- transportPath: ctx.transportPath,
940
- statusCode: 200,
941
- outcome: pushed.response.status,
942
- responseStatus: normalizeResponseStatus(200, pushed.response.status),
1213
+ recordPushExecutionSideEffects(ctx, {
943
1214
  durationMs: pushDurationMs,
944
- errorCode: firstPushErrorCode(pushed.response.results),
945
- commitSeq: pushed.response.commitSeq,
1215
+ outcome: pushed.response.status,
1216
+ commitSeq: pushed.response.commitSeq ?? null,
946
1217
  operationCount: pushOps.length,
947
1218
  tables: pushed.affectedTables,
1219
+ results: pushed.response.results,
948
1220
  payloadSnapshot: shouldCaptureRequestPayloadSnapshots
949
1221
  ? {
950
1222
  request: {
@@ -956,97 +1228,18 @@ export function createSyncRoutes<
956
1228
  response: pushed.response,
957
1229
  }
958
1230
  : null,
959
- }));
960
-
961
- emitConsoleLiveEvent(consoleLiveEmitter, 'push', () => ({
962
- partitionId: ctx.partitionId,
963
- requestId: ctx.requestId,
964
- traceId: ctx.traceContext.traceId,
965
- spanId: ctx.traceContext.spanId,
966
- actorId: ctx.auth.actorId,
967
- clientId: ctx.clientId,
968
- transportPath: ctx.transportPath,
969
- syncPath: ctx.syncPath,
970
- outcome: pushed.response.status,
971
- statusCode: 200,
972
- durationMs: pushDurationMs,
973
- commitSeq: pushed.response.commitSeq ?? null,
974
- operationCount: pushOps.length,
975
- tables: pushed.affectedTables,
976
- }));
977
-
978
- if (execOptions.countConflictsMetric === true) {
979
- const detectedConflicts = pushed.response.results.reduce(
980
- (count, result) => count + (result.status === 'conflict' ? 1 : 0),
981
- 0
982
- );
983
- if (detectedConflicts > 0) {
984
- countSyncMetric('sync.conflicts.detected', detectedConflicts, {
985
- attributes: {
986
- syncPath: ctx.syncPath,
987
- transportPath: ctx.transportPath,
988
- },
989
- });
990
- }
991
- }
992
-
993
- if (
994
- wsConnectionManager &&
995
- pushed.response.ok === true &&
996
- pushed.response.status === 'applied' &&
997
- typeof pushed.response.commitSeq === 'number'
998
- ) {
999
- const scopeKeys = applyPartitionToScopeKeys(
1000
- ctx.partitionId,
1001
- pushed.scopeKeys
1002
- );
1003
-
1004
- if (scopeKeys.length > 0) {
1005
- wsConnectionManager.notifyScopeKeys(
1006
- scopeKeys,
1007
- pushed.response.commitSeq,
1008
- {
1009
- excludeClientIds: [ctx.clientId],
1010
- changes: pushed.emittedChanges,
1011
- actorId: pushed.commitActorId ?? ctx.auth.actorId,
1012
- createdAt: pushed.commitCreatedAt ?? new Date().toISOString(),
1013
- }
1014
- );
1231
+ });
1015
1232
 
1016
- if (realtimeBroadcaster) {
1017
- realtimeBroadcaster
1018
- .publish({
1019
- type: 'commit',
1020
- commitSeq: pushed.response.commitSeq,
1021
- partitionId: ctx.partitionId,
1022
- scopeKeys,
1023
- sourceInstanceId: instanceId,
1024
- })
1025
- .catch((error) => {
1026
- logAsyncFailureOnce('sync.realtime.broadcast_publish_failed', {
1027
- event: 'sync.realtime.broadcast_publish_failed',
1028
- userId: ctx.auth.actorId,
1029
- clientId: ctx.clientId,
1030
- error: error instanceof Error ? error.message : String(error),
1031
- });
1032
- });
1033
- }
1034
- }
1035
- }
1233
+ maybeCountPushConflicts(
1234
+ ctx,
1235
+ pushed.response.results,
1236
+ execOptions.countConflictsMetric
1237
+ );
1036
1238
 
1037
- if (
1038
- pushed.response.ok === true &&
1039
- pushed.response.status === 'applied' &&
1040
- typeof pushed.response.commitSeq === 'number'
1041
- ) {
1042
- emitConsoleLiveEvent(consoleLiveEmitter, 'commit', () => ({
1043
- partitionId: ctx.partitionId,
1044
- commitSeq: pushed.response.commitSeq,
1045
- actorId: ctx.auth.actorId,
1046
- clientId: ctx.clientId,
1047
- affectedTables: pushed.affectedTables,
1048
- }));
1239
+ if (execOptions.deferRealtimeNotifications !== true) {
1240
+ notifyRealtimeForAppliedPushes(ctx, [pushed]);
1049
1241
  }
1242
+ emitCommitLiveEvents(ctx, [pushed]);
1050
1243
 
1051
1244
  return pushed;
1052
1245
  }
@@ -1465,36 +1658,85 @@ export function createSyncRoutes<
1465
1658
 
1466
1659
  let pushResponse:
1467
1660
  | undefined
1468
- | Awaited<ReturnType<typeof pushCommit>>['response'];
1661
+ | {
1662
+ ok: true;
1663
+ commits: Array<
1664
+ Awaited<ReturnType<typeof pushCommit>>['response'] & {
1665
+ clientCommitId: string;
1666
+ }
1667
+ >;
1668
+ };
1469
1669
  let pullResponse: undefined | PullResult['response'];
1470
1670
 
1471
1671
  // --- Push phase ---
1472
1672
  if (body.push) {
1473
- const pushBody = body.push;
1474
- const pushOps = pushBody.operations ?? [];
1475
- if (pushOps.length > maxOperationsPerPush) {
1476
- return c.json(
1477
- {
1478
- error: 'TOO_MANY_OPERATIONS',
1479
- message: `Maximum ${maxOperationsPerPush} operations per push`,
1480
- },
1481
- 400
1482
- );
1673
+ const pushBodies = body.push.commits ?? [];
1674
+ const pushedCommits: NonNullable<typeof pushResponse>['commits'] = [];
1675
+ for (const pushBody of pushBodies) {
1676
+ const pushOps = pushBody.operations ?? [];
1677
+ if (pushOps.length > maxOperationsPerPush) {
1678
+ return c.json(
1679
+ {
1680
+ error: 'TOO_MANY_OPERATIONS',
1681
+ message: `Maximum ${maxOperationsPerPush} operations per push`,
1682
+ },
1683
+ 400
1684
+ );
1685
+ }
1686
+ }
1687
+ const executedPushes =
1688
+ pushBodies.length > 1
1689
+ ? await executePushCommitBatchWithSideEffects(
1690
+ {
1691
+ auth,
1692
+ clientId,
1693
+ partitionId,
1694
+ requestId,
1695
+ traceContext,
1696
+ transportPath,
1697
+ syncPath: 'http-combined',
1698
+ },
1699
+ pushBodies,
1700
+ {
1701
+ countConflictsMetric: true,
1702
+ }
1703
+ )
1704
+ : [];
1705
+
1706
+ for (let index = 0; index < pushBodies.length; index += 1) {
1707
+ const pushBody = pushBodies[index];
1708
+ if (!pushBody) continue;
1709
+ const pushed =
1710
+ pushBodies.length > 1
1711
+ ? executedPushes[index]
1712
+ : await executePushCommitWithSideEffects(
1713
+ {
1714
+ auth,
1715
+ clientId,
1716
+ partitionId,
1717
+ requestId,
1718
+ traceContext,
1719
+ transportPath,
1720
+ syncPath: 'http-combined',
1721
+ },
1722
+ pushBody,
1723
+ {
1724
+ countConflictsMetric: true,
1725
+ }
1726
+ );
1727
+ if (!pushed) {
1728
+ throw new Error('Server returned incomplete batched push result');
1729
+ }
1730
+ pushedCommits.push({
1731
+ clientCommitId: pushBody.clientCommitId,
1732
+ ...pushed.response,
1733
+ });
1483
1734
  }
1484
- const pushed = await executePushCommitWithSideEffects(
1485
- {
1486
- auth,
1487
- clientId,
1488
- partitionId,
1489
- requestId,
1490
- traceContext,
1491
- transportPath,
1492
- syncPath: 'http-combined',
1493
- },
1494
- pushBody
1495
- );
1496
1735
 
1497
- pushResponse = pushed.response;
1736
+ pushResponse = {
1737
+ ok: true,
1738
+ commits: pushedCommits,
1739
+ };
1498
1740
  }
1499
1741
 
1500
1742
  // --- Pull phase ---